Skip to content
← Public packages

@kentcdodds/twitch

Monitor Twitch streams, send chat, and update channel settings.

src/core.ts

380 lines · 12.7 KB · TypeScript
import { kody } from 'kody:runtime'
import {
	API_BASE_URL,
	getChatters as helixGetChatters,
	getStreams as helixGetStreams,
	getUsers as helixGetUsers,
	modifyChannelInformation as helixModifyChannelInformation,
	rawHelixRequest,
	searchCategories as helixSearchCategories,
	sendChatMessage as helixSendChatMessage,
	type QueryInput,
	type ScaffoldInput,
	type ScaffoldOptions,
} from './openapi-client.ts'
import type {
	TwitchChatter,
	TwitchListChattersParams,
	TwitchListChattersResult,
	TwitchRequestOptions,
	TwitchResponse,
	TwitchSendChatMessageParams,
	TwitchSendChatMessageResult,
	TwitchStream,
	TwitchStreamStatus,
	TwitchUpdateChannelInfoParams,
	TwitchUpdateChannelInfoResult,
	TwitchUser,
} from './types.ts'
import { formatTwitchScopes, TWITCH_DEFAULT_SCOPES } from './scopes.ts'

export const TWITCH_HELIX_BASE_URL = API_BASE_URL
export const TWITCH_INTEGRATION = 'twitch'

export type { QueryInput, ScaffoldInput, ScaffoldOptions }

export class TwitchRequestError extends Error {
	response: TwitchResponse

	constructor(response: TwitchResponse) {
		super(buildErrorMessage(response))
		this.name = 'TwitchRequestError'
		this.response = response
	}
}

async function getClientId(): Promise<string> {
	const got = await kody.integrationGet({ name: TWITCH_INTEGRATION })
	const integration = (
		got && typeof got === 'object' && 'integration' in got
			? (got as { integration: unknown }).integration
			: got
	) as { clientId?: unknown } | null
	const clientId = integration?.clientId
	if (!clientId || typeof clientId !== 'string') {
		throw new Error(
			`Twitch integration "${TWITCH_INTEGRATION}" has no client id. Connect it at https://kody.codes/connect/oauth?provider=${TWITCH_INTEGRATION}`,
		)
	}
	return clientId
}

/**
 * Helix requires BOTH Authorization Bearer and Client-Id.
 * Authorization comes from createAuthenticatedFetch('twitch') in the OpenAPI client.
 * This helper supplies Client-Id (and Accept) via input.headers merge.
 */
export async function helixClientHeaders(
	extra: Record<string, string> = {},
): Promise<Record<string, string>> {
	const clientId = await getClientId()
	return {
		...extra,
		Accept: 'application/json',
		'Client-Id': clientId,
	}
}

async function parseResponseBody(text: string, headers: Headers): Promise<unknown> {
	if (!text) return null
	const contentType = headers.get('content-type') ?? ''
	if (contentType.includes('json') || text.startsWith('{') || text.startsWith('[')) {
		try {
			return JSON.parse(text)
		} catch {
			return text
		}
	}
	return text
}

function extractTwitchMessage(data: unknown): string | null {
	if (data && typeof data === 'object' && 'message' in data) {
		const message = (data as { message: unknown }).message
		return typeof message === 'string' ? message : null
	}
	return null
}

function buildErrorMessage(response: TwitchResponse): string {
	const message = extractTwitchMessage(response.data)
	return message
		? `Twitch request failed with ${response.status}: ${message}`
		: `Twitch request failed with ${response.status} ${response.statusText}`
}

async function toTwitchResponse<T = unknown>(
	fetchResponse: Response,
	throwOnError = false,
): Promise<TwitchResponse<T>> {
	const text = await fetchResponse.text()
	const data = (await parseResponseBody(text, fetchResponse.headers)) as T
	const response: TwitchResponse<T> = {
		url: fetchResponse.url,
		ok: fetchResponse.ok,
		status: fetchResponse.status,
		statusText: fetchResponse.statusText,
		data,
		text,
	}
	if (!response.ok && throwOnError) throw new TwitchRequestError(response)
	return response
}

async function callHelix(
	fn: (input?: ScaffoldInput, options?: ScaffoldOptions) => Promise<Response>,
	input: ScaffoldInput = {},
	options: { throwOnError?: boolean; fetchImpl?: typeof fetch } = {},
): Promise<TwitchResponse> {
	const headers = await helixClientHeaders(input.headers)
	const fetchResponse = await fn(
		{ ...input, headers },
		{ fetchImpl: options.fetchImpl },
	)
	return toTwitchResponse(fetchResponse, options.throwOnError ?? true)
}

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 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 : undefined,
		broadcasterType: typeof raw.broadcaster_type === 'string' ? raw.broadcaster_type : undefined,
		description: typeof raw.description === 'string' ? raw.description : undefined,
		profileImageUrl: typeof raw.profile_image_url === 'string' ? raw.profile_image_url : undefined,
		viewCount: typeof raw.view_count === 'number' ? raw.view_count : undefined,
		createdAt: typeof raw.created_at === 'string' ? raw.created_at : undefined,
		email: typeof raw.email === 'string' ? raw.email : undefined,
	}
}

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,
		isLive: true,
	}
}

/** Low-level authenticated Helix helper (escape hatch). */
export async function request<T = unknown>(
	options: TwitchRequestOptions,
): Promise<TwitchResponse<T>> {
	const headers = await helixClientHeaders(options.headers)
	const fetchResponse = await rawHelixRequest(options.path, {
		method: options.method,
		query: options.query as QueryInput | undefined,
		headers,
		body: options.body,
	})
	return toTwitchResponse<T>(fetchResponse, options.throwOnError ?? false)
}

export async function getUsers(options: {
	logins?: string[]
	ids?: 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 callHelix(helixGetUsers, { query })
	return asDataArray(response.data).map(normalizeUser)
}

export async function getUserByLogin(login: string): Promise<TwitchUser> {
	const user = (await getUsers({ logins: [login] }))[0]
	if (!user) throw new Error('Twitch user not found for login: ' + login)
	return user
}

export async function getMe(): Promise<TwitchUser> {
	const response = await callHelix(helixGetUsers, {})
	const raw = asDataArray(response.data)[0]
	if (!raw) throw new Error('Twitch /users response did not include the authenticated user.')
	return normalizeUser(raw)
}

export async function getStreamStatus(options: {
	login?: string
	logins?: string[]
}): Promise<TwitchStreamStatus[]> {
	const logins = options.logins ?? (options.login ? [options.login] : [])
	if (logins.length === 0) {
		throw new Error('Provide login or logins when reading Twitch stream status.')
	}
	const response = await callHelix(helixGetStreams, {
		query: { user_login: logins },
	})
	const liveByLogin = new Map(
		asDataArray(response.data).map((stream) => [
			String(stream.user_login).toLowerCase(),
			normalizeStream(stream),
		]),
	)
	return logins.map(
		(login) => liveByLogin.get(login.toLowerCase()) ?? { userLogin: login, isLive: false },
	)
}

function requireNonEmptyMessage(message: unknown): string {
	if (typeof message !== 'string' || message.trim().length === 0) {
		throw new Error('sendChatMessage requires a non-empty message string.')
	}
	if (message.length > 500) {
		throw new Error('Twitch chat messages must be 500 characters or fewer.')
	}
	return message
}

export async function sendChatMessage(
	options: TwitchSendChatMessageParams,
): Promise<TwitchSendChatMessageResult> {
	if (!options || typeof options.broadcasterLogin !== 'string' || !options.broadcasterLogin.trim()) {
		throw new Error('sendChatMessage requires broadcasterLogin.')
	}
	const message = requireNonEmptyMessage(options.message)
	const broadcaster = await getUserByLogin(options.broadcasterLogin.trim())
	const sender = await getMe()
	const response = await callHelix(helixSendChatMessage, {
		body: {
			broadcaster_id: broadcaster.id,
			sender_id: sender.id,
			message,
		},
	})
	const result = asDataArray(response.data)[0] ?? {}
	return {
		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 searchCategories(options: {
	query: string
	limit?: number
}): Promise<Array<{ id: string; name: string; boxArtUrl?: string }>> {
	if (!options?.query || typeof options.query !== 'string') {
		throw new Error('searchCategories requires a query string.')
	}
	const response = await callHelix(helixSearchCategories, {
		query: { query: options.query, first: options.limit ?? 5 },
	})
	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 updateChannelInfo(
	options: TwitchUpdateChannelInfoParams = {},
): Promise<TwitchUpdateChannelInfoResult> {
	const login = options.broadcasterLogin ?? (await getMe()).login
	const broadcaster = await getUserByLogin(login)
	const body: Record<string, string> = {}
	if (options.title !== undefined) body.title = options.title
	if (options.gameId !== undefined) body.game_id = options.gameId
	if (Object.keys(body).length === 0 && !options.gameName) {
		throw new Error('Provide title, gameId, or gameName when updating Twitch channel info.')
	}
	if (options.gameName && !options.gameId) {
		const match = (await searchCategories({ query: options.gameName, limit: 1 }))[0]
		if (!match) throw new Error('Twitch category not found for gameName: ' + options.gameName)
		body.game_id = match.id
	}
	const response = await callHelix(helixModifyChannelInformation, {
		query: { broadcaster_id: broadcaster.id },
		body,
	})
	return { broadcasterLogin: broadcaster.login, updated: body, status: response.status }
}

export async function listChatters(
	options: TwitchListChattersParams = {},
): Promise<TwitchListChattersResult> {
	const broadcasterLogin = options.broadcasterLogin ?? (await getMe()).login
	const broadcaster = await getUserByLogin(broadcasterLogin)
	const moderator = await getMe()
	const after = options.after ?? options.cursor
	const query: QueryInput = {
		broadcaster_id: broadcaster.id,
		moderator_id: moderator.id,
		first: options.limit ?? 100,
	}
	if (after) query.after = after
	const response = await callHelix(helixGetChatters, { query })
	const data = response.data as {
		total?: number
		data?: Array<Record<string, unknown>>
		pagination?: { cursor?: string }
	} | null
	const chatters: TwitchChatter[] = (data?.data ?? []).map((c) => ({
		userId: String(c.user_id),
		userLogin: String(c.user_login),
		userName: String(c.user_name),
	}))
	return {
		broadcasterLogin: broadcaster.login,
		total: data?.total ?? chatters.length,
		chatters,
		cursor: data?.pagination?.cursor ?? null,
	}
}

export async function runSmokeTest() {
	const me = await getMe()
	const [streamStatus] = await getStreamStatus({ login: me.login })
	return {
		ok: true as const,
		login: me.login,
		displayName: me.displayName,
		broadcasterType: me.broadcasterType,
		stream: streamStatus,
	}
}

export function getConnectUrl(
	origin = 'https://kody.codes',
	options: { scopes?: string[] } = {},
): string {
	const params = new URLSearchParams({
		provider: 'twitch',
		authorizeUrl: 'https://id.twitch.tv/oauth2/authorize',
		tokenUrl: 'https://id.twitch.tv/oauth2/token',
		allowedHosts: 'api.twitch.tv,id.twitch.tv',
		apiBaseUrl: TWITCH_HELIX_BASE_URL,
		scopes: formatTwitchScopes(options.scopes ?? TWITCH_DEFAULT_SCOPES),
		flow: 'confidential',
		dashboardUrl: 'https://dev.twitch.tv/console/apps',
		providerSetupInstructions:
			'Create a Twitch application at dev.twitch.tv. Set OAuth Redirect URL to ' +
			origin +
			'/connect/oauth. Choose Application Integration as the category. Copy the Client ID and Client Secret into the connect wizard.',
	})
	return origin + '/connect/oauth?' + params.toString()
}