Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/twitch

src/client.ts

808 lines · 26.9 KB · TypeScript
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 }