import { createAuthenticatedFetch, kody } from 'kody:runtime'
import {
boundedLimit,
inputRecord,
optionalBoolean,
optionalString,
requiredString,
stringList,
type InputRecord,
} from './validation.ts'
export const TWITCH_INTEGRATION = 'twitch'
export const TWITCH_HELIX_BASE = 'https://api.twitch.tv/helix'
export const TWITCH_AUTHORIZE_URL = 'https://id.twitch.tv/oauth2/authorize'
export const TWITCH_TOKEN_URL = 'https://id.twitch.tv/oauth2/token'
export const TWITCH_DASHBOARD_URL = 'https://dev.twitch.tv/console/apps'
export const TWITCH_CALLBACK_URL = 'https://kody.codes/connect/oauth'
export const TWITCH_ALLOWED_HOSTS = ['api.twitch.tv', 'id.twitch.tv'] as const
export const TWITCH_SCOPES = [
'user:read:follows',
'moderator:read:followers',
'user:write:chat',
'moderator:read:chatters',
'channel:manage:broadcast',
] as const
export type QueryValue = string | number | boolean | null | undefined
export type QueryInput = Record<string, QueryValue | QueryValue[]>
export type TwitchUser = {
id: string
login: string
displayName: string
type?: string
broadcasterType?: string
description?: string
profileImageUrl?: string
offlineImageUrl?: string
createdAt?: string
hasEmail: boolean
}
export type TwitchChannel = {
broadcasterId: string
broadcasterLogin: string
broadcasterName: string
broadcasterLanguage?: string
gameId?: string
gameName?: string
title?: string
delay?: number
tags?: string[]
}
export type TwitchStream = {
id: string
userId: string
userLogin: string
userName: string
gameId?: string
gameName?: string
type?: string
title?: string
viewerCount?: number
startedAt?: string
language?: string
thumbnailUrl?: string
tags?: string[]
isLive: true
}
export type TwitchStreamStatus = TwitchStream | { userLogin: string; isLive: false }
export type TwitchFollowedChannel = {
broadcasterId: string
broadcasterLogin: string
broadcasterName: string
followedAt?: string
}
export type TwitchFollower = {
userId: string
userLogin: string
userName: string
followedAt?: string
}
export type TwitchChatter = {
userId: string
userLogin: string
userName: string
}
export type TwitchResponse<T = unknown> = {
url: string
ok: boolean
status: number
statusText: string
data: T
text: string
}
export class TwitchApiError extends Error {
readonly status: number
readonly statusText: string
readonly integration: string
readonly details: unknown
constructor(
message: string,
options: { status: number; statusText: string; integration: string; details: unknown },
) {
super(message)
this.name = 'TwitchApiError'
this.status = options.status
this.statusText = options.statusText
this.integration = options.integration
this.details = options.details
}
}
const authenticatedFetches = new Map<string, typeof fetch>()
const clientIds = new Map<string, string>()
export function resolveIntegrationName(value: unknown): string {
if (value === undefined || value === null || value === '') return TWITCH_INTEGRATION
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error('integration must be a non-empty string (Kody OAuth connection name).')
}
return value.trim()
}
export function connectOauthUrl(integration: string = TWITCH_INTEGRATION): string {
return TWITCH_CALLBACK_URL + '?provider=' + encodeURIComponent(integration)
}
export function byoConnectUrl(integration: string = TWITCH_INTEGRATION): string {
const url = new URL(TWITCH_CALLBACK_URL)
url.searchParams.set('provider', integration)
url.searchParams.set('authorizeUrl', TWITCH_AUTHORIZE_URL)
url.searchParams.set('tokenUrl', TWITCH_TOKEN_URL)
url.searchParams.set('apiBaseUrl', TWITCH_HELIX_BASE)
url.searchParams.set('flow', 'confidential')
url.searchParams.set('tokenExchangeStyle', 'form')
url.searchParams.set('allowedHosts', TWITCH_ALLOWED_HOSTS.join(','))
url.searchParams.set('dashboardUrl', TWITCH_DASHBOARD_URL)
url.searchParams.set('scopes', TWITCH_SCOPES.join(' '))
url.searchParams.set(
'providerSetupInstructions',
'Create a Twitch application at dev.twitch.tv. Set OAuth Redirect URL to https://kody.codes/connect/oauth. Choose Application Integration as the category. Paste the Client ID and Client Secret into this connect page — not into chat.',
)
return url.toString()
}
export function requireWriteConfirmation(input: InputRecord, action: string): void {
if (input.dryRun === true) return
if (input.confirm !== true) {
throw new Error(
action +
' requires confirm: true after explicit user approval of the exact payload. Pass dryRun: true to preview.',
)
}
}
function asDataArray(data: unknown): Array<Record<string, unknown>> {
if (!data || typeof data !== 'object') return []
const rows = (data as { data?: unknown }).data
return Array.isArray(rows) ? (rows as Array<Record<string, unknown>>) : []
}
function paginationCursor(data: unknown): string | null {
if (!data || typeof data !== 'object') return null
const cursor = (data as { pagination?: { cursor?: unknown } }).pagination?.cursor
return typeof cursor === 'string' && cursor.length > 0 ? cursor : null
}
function extractTwitchMessage(data: unknown): string | null {
if (!data || typeof data !== 'object') return null
const message = (data as { message?: unknown }).message
return typeof message === 'string' ? message : null
}
async function getClientId(integration: string): Promise<string> {
const cached = clientIds.get(integration)
if (cached) return cached
const got = await kody.integration_get({ name: integration })
const record = (
got && typeof got === 'object' && 'integration' in got
? (got as { integration: unknown }).integration
: got
) as { clientId?: unknown } | null
const clientId = record?.clientId
if (!clientId || typeof clientId !== 'string') {
throw new Error(
'Twitch integration "' +
integration +
'" has no client id. Connect it at ' +
byoConnectUrl(integration),
)
}
clientIds.set(integration, clientId)
return clientId
}
async function getAuthenticatedFetch(integration: string): Promise<typeof fetch> {
const cached = authenticatedFetches.get(integration)
if (cached) return cached
const created = await createAuthenticatedFetch(integration)
authenticatedFetches.set(integration, created)
return created
}
function appendQuery(url: string, query: QueryInput = {}): string {
const search = new URLSearchParams()
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null) continue
if (Array.isArray(value)) {
for (const item of value) {
if (item === undefined || item === null) continue
search.append(key, String(item))
}
continue
}
search.append(key, String(value))
}
const qs = search.toString()
return qs ? url + '?' + qs : url
}
function helixUrl(path: string, query: QueryInput = {}): string {
if (typeof path !== 'string' || path.length === 0) {
throw new Error('Twitch Helix path is required.')
}
if (path.startsWith('http://') || path.startsWith('https://')) {
throw new Error('Pass a Helix-relative path such as /users, not an absolute URL.')
}
if (!path.startsWith('/')) {
throw new Error('Helix path must start with "/" (relative to api.twitch.tv/helix).')
}
return appendQuery(TWITCH_HELIX_BASE + path, query)
}
async function parseResponseBody(text: string): Promise<unknown> {
if (!text) return null
try {
return JSON.parse(text)
} catch {
return text
}
}
function formatTwitchError(
status: number,
statusText: string,
data: unknown,
integration: string,
): string {
const message = extractTwitchMessage(data)
const reconnect = connectOauthUrl(integration)
const suffix = message ? ': ' + message : ' ' + statusText
if (status === 401) {
return (
'Twitch request failed with 401' +
suffix +
'. Reconnect the ' +
integration +
' OAuth integration at ' +
reconnect +
'.'
)
}
if (status === 403) {
return (
'Twitch request failed with 403' +
suffix +
'. A required scope is missing. Add it on the Twitch app, then reconnect at ' +
reconnect +
'. See the @kody/twitch README scopes table.'
)
}
return 'Twitch request failed with ' + status + suffix
}
export async function helixRequest<T = unknown>(
path: string,
options: {
method?: string
query?: QueryInput
body?: unknown
headers?: Record<string, string>
integration?: string
throwOnError?: boolean
} = {},
): Promise<TwitchResponse<T>> {
const integration = resolveIntegrationName(options.integration)
const method = (options.method ?? (options.body === undefined ? 'GET' : 'POST')).toUpperCase()
const url = helixUrl(path, options.query)
const clientId = await getClientId(integration)
const headers: Record<string, string> = {
accept: 'application/json',
'client-id': clientId,
...(options.headers ?? {}),
}
let body: string | undefined
if (options.body !== undefined) {
body = JSON.stringify(options.body)
if (!Object.keys(headers).some((key) => key.toLowerCase() === 'content-type')) {
headers['content-type'] = 'application/json'
}
}
const authedFetch = await getAuthenticatedFetch(integration)
const fetchResponse = await authedFetch(url, { method, headers, body })
const text = await fetchResponse.text()
const data = (await parseResponseBody(text)) as T
const response: TwitchResponse<T> = {
url: fetchResponse.url,
ok: fetchResponse.ok,
status: fetchResponse.status,
statusText: fetchResponse.statusText,
data,
text,
}
if (!response.ok && (options.throwOnError ?? true)) {
throw new TwitchApiError(formatTwitchError(response.status, response.statusText, data, integration), {
status: response.status,
statusText: response.statusText,
integration,
details: data,
})
}
return response
}
export function normalizeUser(raw: Record<string, unknown>): TwitchUser {
return {
id: String(raw.id),
login: String(raw.login),
displayName: String(raw.display_name ?? raw.login),
type: typeof raw.type === 'string' && raw.type ? raw.type : undefined,
broadcasterType:
typeof raw.broadcaster_type === 'string' && raw.broadcaster_type
? raw.broadcaster_type
: undefined,
description: typeof raw.description === 'string' ? raw.description : undefined,
profileImageUrl: typeof raw.profile_image_url === 'string' ? raw.profile_image_url : undefined,
offlineImageUrl: typeof raw.offline_image_url === 'string' ? raw.offline_image_url : undefined,
createdAt: typeof raw.created_at === 'string' ? raw.created_at : undefined,
hasEmail: typeof raw.email === 'string' && raw.email.length > 0,
}
}
export function normalizeChannel(raw: Record<string, unknown>): TwitchChannel {
return {
broadcasterId: String(raw.broadcaster_id),
broadcasterLogin: String(raw.broadcaster_login),
broadcasterName: String(raw.broadcaster_name ?? raw.broadcaster_login),
broadcasterLanguage:
typeof raw.broadcaster_language === 'string' ? raw.broadcaster_language : undefined,
gameId: typeof raw.game_id === 'string' ? raw.game_id : undefined,
gameName: typeof raw.game_name === 'string' ? raw.game_name : undefined,
title: typeof raw.title === 'string' ? raw.title : undefined,
delay: typeof raw.delay === 'number' ? raw.delay : undefined,
tags: Array.isArray(raw.tags) ? raw.tags.map(String) : undefined,
}
}
export function normalizeStream(raw: Record<string, unknown>): TwitchStream {
return {
id: String(raw.id),
userId: String(raw.user_id),
userLogin: String(raw.user_login),
userName: String(raw.user_name),
gameId: typeof raw.game_id === 'string' ? raw.game_id : undefined,
gameName: typeof raw.game_name === 'string' ? raw.game_name : undefined,
type: typeof raw.type === 'string' ? raw.type : undefined,
title: typeof raw.title === 'string' ? raw.title : undefined,
viewerCount: typeof raw.viewer_count === 'number' ? raw.viewer_count : undefined,
startedAt: typeof raw.started_at === 'string' ? raw.started_at : undefined,
language: typeof raw.language === 'string' ? raw.language : undefined,
thumbnailUrl: typeof raw.thumbnail_url === 'string' ? raw.thumbnail_url : undefined,
tags: Array.isArray(raw.tags) ? raw.tags.map(String) : undefined,
isLive: true,
}
}
export async function getUsers(options: {
logins?: string[]
ids?: string[]
integration?: string
} = {}): Promise<TwitchUser[]> {
const query: QueryInput = {}
if (options.logins?.length) query.login = options.logins
if (options.ids?.length) query.id = options.ids
const response = await helixRequest('/users', { query, integration: options.integration })
return asDataArray(response.data).map(normalizeUser)
}
export async function getMe(integration?: string): Promise<TwitchUser> {
const users = await getUsers({ integration })
const me = users[0]
if (!me) {
throw new Error(
'Twitch /users did not include the authenticated user. Reconnect at ' +
connectOauthUrl(resolveIntegrationName(integration)) +
'.',
)
}
return me
}
export async function getUserByLogin(login: string, integration?: string): Promise<TwitchUser> {
const user = (await getUsers({ logins: [login], integration }))[0]
if (!user) throw new Error('Twitch user not found for login: ' + login)
return user
}
export async function getUserById(id: string, integration?: string): Promise<TwitchUser> {
const user = (await getUsers({ ids: [id], integration }))[0]
if (!user) throw new Error('Twitch user not found for id: ' + id)
return user
}
export async function resolveUser(options: {
login?: string
id?: string
integration?: string
}): Promise<TwitchUser> {
if (options.id) return getUserById(options.id, options.integration)
if (options.login) return getUserByLogin(options.login, options.integration)
return getMe(options.integration)
}
export async function lookupUsers(params: Record<string, unknown> = {}): Promise<TwitchUser[]> {
const input = inputRecord(params)
const integration = resolveIntegrationName(input.integration)
const logins = [
...stringList(input.login, 'login'),
...stringList(input.logins, 'logins'),
]
const ids = [...stringList(input.id, 'id'), ...stringList(input.ids, 'ids')]
if (logins.length === 0 && ids.length === 0) {
throw new Error('Provide login, logins, id, or ids when looking up Twitch users.')
}
if (logins.length + ids.length > 100) {
throw new Error('Twitch Get Users accepts at most 100 login and id values combined.')
}
return getUsers({ logins, ids, integration })
}
export async function getChannels(options: {
ids: string[]
integration?: string
}): Promise<TwitchChannel[]> {
if (options.ids.length === 0) {
throw new Error('Provide id, ids, login, or logins when looking up Twitch channels.')
}
const response = await helixRequest('/channels', {
query: { broadcaster_id: options.ids },
integration: options.integration,
})
return asDataArray(response.data).map(normalizeChannel)
}
export async function lookupChannels(params: Record<string, unknown> = {}): Promise<TwitchChannel[]> {
const input = inputRecord(params)
const integration = resolveIntegrationName(input.integration)
const logins = [
...stringList(input.login, 'login'),
...stringList(input.logins, 'logins'),
]
const ids = [...stringList(input.id, 'id'), ...stringList(input.ids, 'ids')]
const resolvedIds = [...ids]
if (logins.length > 0) {
const users = await getUsers({ logins, integration })
const found = new Set(users.map((user) => user.login.toLowerCase()))
const missing = logins.filter((login) => !found.has(login.toLowerCase()))
if (missing.length > 0) {
throw new Error('Twitch user not found for login: ' + missing.join(', '))
}
resolvedIds.push(...users.map((user) => user.id))
}
if (resolvedIds.length === 0) {
const me = await getMe(integration)
resolvedIds.push(me.id)
}
return getChannels({ ids: resolvedIds, integration })
}
export async function getStreamStatus(params: Record<string, unknown> = {}): Promise<TwitchStreamStatus[]> {
const input = inputRecord(params)
const integration = resolveIntegrationName(input.integration)
const logins = [
...stringList(input.login, 'login'),
...stringList(input.logins, 'logins'),
]
const ids = [...stringList(input.id, 'id'), ...stringList(input.ids, 'ids')]
if (logins.length === 0 && ids.length === 0) {
const me = await getMe(integration)
logins.push(me.login)
}
const query: QueryInput = {}
if (logins.length) query.user_login = logins
if (ids.length) query.user_id = ids
const response = await helixRequest('/streams', { query, integration })
const live = asDataArray(response.data).map(normalizeStream)
const liveByLogin = new Map(live.map((stream) => [stream.userLogin.toLowerCase(), stream]))
const liveById = new Map(live.map((stream) => [stream.userId, stream]))
const statuses: TwitchStreamStatus[] = []
for (const login of logins) {
statuses.push(liveByLogin.get(login.toLowerCase()) ?? { userLogin: login, isLive: false })
}
for (const id of ids) {
const match = liveById.get(id)
if (match) statuses.push(match)
else statuses.push({ userLogin: id, isLive: false })
}
return statuses
}
export async function listFollows(params: Record<string, unknown> = {}) {
const input = inputRecord(params)
const integration = resolveIntegrationName(input.integration)
const me = await getMe(integration)
const user = input.userLogin || input.userId
? await resolveUser({
login: optionalString(input, 'userLogin'),
id: optionalString(input, 'userId'),
integration,
})
: me
if (user.id !== me.id) {
throw new Error(
'Get Followed Channels only returns channels followed by the token user. Omit userLogin/userId or pass the authenticated user.',
)
}
const query: QueryInput = {
user_id: user.id,
first: boundedLimit(input.limit, 20),
}
const after = optionalString(input, 'after') ?? optionalString(input, 'cursor')
if (after) query.after = after
const broadcasterLogin = optionalString(input, 'broadcasterLogin')
const broadcasterId = optionalString(input, 'broadcasterId')
if (broadcasterLogin || broadcasterId) {
const broadcaster = await resolveUser({
login: broadcasterLogin,
id: broadcasterId,
integration,
})
query.broadcaster_id = broadcaster.id
}
const response = await helixRequest('/channels/followed', { query, integration })
const payload = response.data as { total?: number } | null
return {
userLogin: user.login,
total: typeof payload?.total === 'number' ? payload.total : asDataArray(response.data).length,
follows: asDataArray(response.data).map((row): TwitchFollowedChannel => ({
broadcasterId: String(row.broadcaster_id),
broadcasterLogin: String(row.broadcaster_login),
broadcasterName: String(row.broadcaster_name ?? row.broadcaster_login),
followedAt: typeof row.followed_at === 'string' ? row.followed_at : undefined,
})),
cursor: paginationCursor(response.data),
}
}
export async function listFollowers(params: Record<string, unknown> = {}) {
const input = inputRecord(params)
const integration = resolveIntegrationName(input.integration)
const broadcaster = await resolveUser({
login: optionalString(input, 'broadcasterLogin') ?? optionalString(input, 'login'),
id: optionalString(input, 'broadcasterId') ?? optionalString(input, 'id'),
integration,
})
const query: QueryInput = {
broadcaster_id: broadcaster.id,
first: boundedLimit(input.limit, 20),
}
const after = optionalString(input, 'after') ?? optionalString(input, 'cursor')
if (after) query.after = after
const userLogin = optionalString(input, 'userLogin')
const userId = optionalString(input, 'userId')
if (userLogin || userId) {
const follower = await resolveUser({ login: userLogin, id: userId, integration })
query.user_id = follower.id
}
const response = await helixRequest('/channels/followers', { query, integration })
const payload = response.data as { total?: number } | null
const followers = asDataArray(response.data).map((row): TwitchFollower => ({
userId: String(row.user_id),
userLogin: String(row.user_login),
userName: String(row.user_name ?? row.user_login),
followedAt: typeof row.followed_at === 'string' ? row.followed_at : undefined,
}))
return {
broadcasterLogin: broadcaster.login,
total: typeof payload?.total === 'number' ? payload.total : followers.length,
followers,
listAvailable: followers.length > 0 || (payload?.total ?? 0) === 0,
cursor: paginationCursor(response.data),
note:
followers.length === 0 && (payload?.total ?? 0) > 0
? 'Helix returned the follower total only. The follower list requires moderator:read:followers and that the token user is the broadcaster or a moderator of that channel.'
: undefined,
}
}
export async function listFollowedStreams(params: Record<string, unknown> = {}) {
const input = inputRecord(params)
const integration = resolveIntegrationName(input.integration)
const me = await getMe(integration)
const query: QueryInput = {
user_id: me.id,
first: boundedLimit(input.limit, 20),
}
const after = optionalString(input, 'after') ?? optionalString(input, 'cursor')
if (after) query.after = after
const response = await helixRequest('/streams/followed', { query, integration })
return {
userLogin: me.login,
streams: asDataArray(response.data).map(normalizeStream),
cursor: paginationCursor(response.data),
}
}
export async function listChatters(params: Record<string, unknown> = {}) {
const input = inputRecord(params)
const integration = resolveIntegrationName(input.integration)
const moderator = await getMe(integration)
const broadcaster = await resolveUser({
login: optionalString(input, 'broadcasterLogin') ?? optionalString(input, 'login'),
id: optionalString(input, 'broadcasterId') ?? optionalString(input, 'id'),
integration,
})
const query: QueryInput = {
broadcaster_id: broadcaster.id,
moderator_id: moderator.id,
first: boundedLimit(input.limit, 100),
}
const after = optionalString(input, 'after') ?? optionalString(input, 'cursor')
if (after) query.after = after
const response = await helixRequest('/chat/chatters', { query, integration })
const payload = response.data as { total?: number } | null
const chatters: TwitchChatter[] = asDataArray(response.data).map((row) => ({
userId: String(row.user_id),
userLogin: String(row.user_login),
userName: String(row.user_name ?? row.user_login),
}))
return {
broadcasterLogin: broadcaster.login,
total: typeof payload?.total === 'number' ? payload.total : chatters.length,
chatters,
cursor: paginationCursor(response.data),
}
}
export async function searchCategories(options: {
query: string
limit?: number
integration?: string
}): Promise<Array<{ id: string; name: string; boxArtUrl?: string }>> {
const response = await helixRequest('/search/categories', {
query: { query: options.query, first: options.limit ?? 5 },
integration: options.integration,
})
return asDataArray(response.data).map((category) => ({
id: String(category.id),
name: String(category.name),
boxArtUrl: typeof category.box_art_url === 'string' ? category.box_art_url : undefined,
}))
}
export async function sendChatMessage(params: Record<string, unknown> = {}) {
const input = inputRecord(params)
const integration = resolveIntegrationName(input.integration)
const broadcasterLogin = requiredString(input, 'broadcasterLogin')
const message = requiredString(input, 'message')
if (message.length > 500) {
throw new Error('Twitch chat messages must be 500 characters or fewer.')
}
const payload = { broadcasterLogin, message }
if (input.dryRun === true) {
return { dryRun: true, method: 'POST /chat/messages', identity: 'authorizing-user', payload }
}
requireWriteConfirmation(input, 'Sending a Twitch chat message')
const broadcaster = await getUserByLogin(broadcasterLogin, integration)
const sender = await getMe(integration)
const response = await helixRequest('/chat/messages', {
method: 'POST',
body: {
broadcaster_id: broadcaster.id,
sender_id: sender.id,
message,
},
integration,
})
const result = asDataArray(response.data)[0] ?? {}
return {
ok: true,
broadcasterLogin: broadcaster.login,
senderLogin: sender.login,
messageId: typeof result.message_id === 'string' ? result.message_id : null,
isSent: result.is_sent === true,
dropReason:
result.drop_reason && typeof result.drop_reason === 'object'
? JSON.stringify(result.drop_reason)
: typeof result.drop_reason === 'string'
? result.drop_reason
: null,
}
}
export async function updateChannelInfo(params: Record<string, unknown> = {}) {
const input = inputRecord(params)
const integration = resolveIntegrationName(input.integration)
const title = optionalString(input, 'title')
const gameId = optionalString(input, 'gameId')
const gameName = optionalString(input, 'gameName')
if (!title && !gameId && !gameName) {
throw new Error('Provide title, gameId, or gameName when updating Twitch channel info.')
}
const body: Record<string, string> = {}
if (title !== undefined) body.title = title
if (gameId !== undefined) body.game_id = gameId
if (gameName && !gameId) {
const match = (await searchCategories({ query: gameName, limit: 1, integration }))[0]
if (!match) throw new Error('Twitch category not found for gameName: ' + gameName)
body.game_id = match.id
}
const broadcaster = await resolveUser({
login: optionalString(input, 'broadcasterLogin'),
id: optionalString(input, 'broadcasterId'),
integration,
})
if (input.dryRun === true) {
return {
dryRun: true,
method: 'PATCH /channels',
broadcasterLogin: broadcaster.login,
updated: body,
}
}
requireWriteConfirmation(input, 'Updating Twitch channel information')
const response = await helixRequest('/channels', {
method: 'PATCH',
query: { broadcaster_id: broadcaster.id },
body,
integration,
})
return {
ok: true,
broadcasterLogin: broadcaster.login,
updated: body,
status: response.status,
}
}
export async function requestHelix(params: Record<string, unknown> = {}) {
const input = inputRecord(params)
const integration = resolveIntegrationName(input.integration)
const path = requiredString(input, 'path')
const method = (optionalString(input, 'method') ?? (input.body === undefined ? 'GET' : 'POST')).toUpperCase()
const query = (input.query && typeof input.query === 'object' && !Array.isArray(input.query)
? (input.query as QueryInput)
: {})
const headers = (input.headers && typeof input.headers === 'object' && !Array.isArray(input.headers)
? (input.headers as Record<string, string>)
: undefined)
if (input.dryRun === true) {
return {
dryRun: true,
method,
url: helixUrl(path, query),
body: input.body ?? null,
headers: headers ? { ...headers, authorization: '[managed twitch OAuth integration]' } : undefined,
}
}
if (method !== 'GET' && method !== 'HEAD') {
if (optionalBoolean(input, 'confirm') !== true) {
throw new Error(
'request performs a non-read Twitch API call. Pass confirm: true only after explicit user approval, or dryRun: true to preview.',
)
}
}
return helixRequest(path, {
method,
query,
body: input.body,
headers,
integration,
throwOnError: optionalBoolean(input, 'throwOnError') ?? false,
})
}
export async function runSmokeTest(params: Record<string, unknown> = {}) {
const input = inputRecord(params)
const integration = resolveIntegrationName(input.integration)
const me = await getMe(integration)
return {
ok: true,
integration,
hasUserId: me.id.length > 0,
hasLogin: me.login.length > 0,
}
}
export { inputRecord }