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()
}