feat: connect Twungeon to live Twitch

This commit is contained in:
2026-08-17 12:02:35 -07:00
parent 5bc70dd9b2
commit df984690ce
13 changed files with 523 additions and 55 deletions
+73 -9
View File
@@ -1,10 +1,74 @@
export interface Config { port:number; publicBaseUrl:string; resurrectionRewardId:string; twitchEnabled:boolean; broadcasterId:string; channelLogin:string; twitchClientId:string; twitchAccessToken:string; extensionSecret:string }
export function loadConfig(env:NodeJS.ProcessEnv=process.env):Config{
const port=Number(env.PORT??3000),twitchEnabled=env.TWITCH_ENABLED==='true',resurrectionRewardId=env.CHANNEL_POINTS_RESURRECTION_REWARD_ID??'local-resurrection'
const missing:string[]=[]
if(!Number.isInteger(port)||port<1||port>65535)throw new Error('PORT must be an integer from 1 to 65535')
if(!resurrectionRewardId.trim())throw new Error('CHANNEL_POINTS_RESURRECTION_REWARD_ID is required')
if(twitchEnabled)for(const key of ['TWITCH_CLIENT_ID','TWITCH_CLIENT_SECRET','TWITCH_BROADCASTER_ID','TWITCH_CHANNEL_LOGIN','TWITCH_BOT_ACCESS_TOKEN','TWITCH_EXTENSION_SECRET','CHANNEL_POINTS_RESURRECTION_REWARD_ID'])if(!env[key])missing.push(key)
if(missing.length)throw new Error(`Missing required Twitch configuration: ${missing.join(', ')}`)
return {port,publicBaseUrl:env.PUBLIC_BASE_URL??`http://localhost:${port}`,resurrectionRewardId,twitchEnabled,broadcasterId:env.TWITCH_BROADCASTER_ID??'local-broadcaster',channelLogin:env.TWITCH_CHANNEL_LOGIN??'local-channel',twitchClientId:env.TWITCH_CLIENT_ID??'',twitchAccessToken:env.TWITCH_BOT_ACCESS_TOKEN??'',extensionSecret:env.TWITCH_EXTENSION_SECRET??''}
export interface Config {
port: number
publicBaseUrl: string
resurrectionRewardId: string
twitchEnabled: boolean
broadcasterId: string
channelLogin: string
twitchClientId: string
twitchClientSecret: string
twitchRedirectUri: string
twitchTokenFile: string
extensionSecret: string
oauthConfigured: boolean
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const port = Number(env.PORT ?? 3000)
const publicBaseUrl = env.PUBLIC_BASE_URL ?? `http://localhost:${port}`
const twitchEnabled = env.TWITCH_ENABLED === 'true'
const resurrectionRewardId = env.CHANNEL_POINTS_RESURRECTION_REWARD_ID ?? 'local-resurrection'
const twitchClientId = env.TWITCH_CLIENT_ID ?? ''
const twitchClientSecret = env.TWITCH_CLIENT_SECRET ?? ''
const channelLogin = env.TWITCH_CHANNEL_LOGIN ?? 'local-channel'
const twitchRedirectUri = env.TWITCH_OAUTH_REDIRECT_URI ?? `${publicBaseUrl}/oauth/callback`
const twitchTokenFile = env.TWITCH_TOKEN_FILE ?? '/var/lib/twungeon/twitch-token.json'
const oauthConfigured = Boolean(twitchClientId && twitchClientSecret && env.TWITCH_CHANNEL_LOGIN)
const missing: string[] = []
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('PORT must be an integer from 1 to 65535')
}
if (!resurrectionRewardId.trim()) {
throw new Error('CHANNEL_POINTS_RESURRECTION_REWARD_ID is required')
}
try {
new URL(publicBaseUrl)
new URL(twitchRedirectUri)
} catch {
throw new Error('PUBLIC_BASE_URL and TWITCH_OAUTH_REDIRECT_URI must be valid absolute URLs')
}
if (Boolean(twitchClientId) !== Boolean(twitchClientSecret)) {
throw new Error('TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET must be configured together')
}
if (twitchEnabled) {
for (const key of [
'TWITCH_CLIENT_ID',
'TWITCH_CLIENT_SECRET',
'TWITCH_BROADCASTER_ID',
'TWITCH_CHANNEL_LOGIN',
'TWITCH_EXTENSION_SECRET',
'CHANNEL_POINTS_RESURRECTION_REWARD_ID'
]) {
if (!env[key]) missing.push(key)
}
}
if (missing.length) {
throw new Error(`Missing required Twitch configuration: ${missing.join(', ')}`)
}
return {
port,
publicBaseUrl,
resurrectionRewardId,
twitchEnabled,
broadcasterId: env.TWITCH_BROADCASTER_ID ?? 'local-broadcaster',
channelLogin,
twitchClientId,
twitchClientSecret,
twitchRedirectUri,
twitchTokenFile,
extensionSecret: env.TWITCH_EXTENSION_SECRET ?? '',
oauthConfigured
}
}
+34 -5
View File
@@ -8,15 +8,18 @@ import { Game } from '../../../packages/domain/src/index.js'
import { generateFloor } from '../../../packages/dungeon-generator/src/index.js'
import { LiveTwitchAdapter, SyntheticTwitchAdapter } from '../../../packages/twitch-adapter/src/index.js'
import { loadConfig } from './config.js'
import { TwitchOAuthService } from './twitchOAuth.js'
const config=loadConfig(), startedAt=Date.now();let id=0
const game=new Game({clock:{now:()=>Date.now()},random:{next:()=>Math.random()},ids:{next:p=>`${p}-${++id}`},generateFloor,resurrectionRewardId:config.resurrectionRewardId})
const twitch=config.twitchEnabled?new LiveTwitchAdapter({clientId:config.twitchClientId,accessToken:config.twitchAccessToken,broadcasterId:config.broadcasterId,channelLogin:config.channelLogin,extensionSecret:config.extensionSecret,resurrectionRewardId:config.resurrectionRewardId}):new SyntheticTwitchAdapter(config.broadcasterId)
const oauth=config.oauthConfigured?new TwitchOAuthService({clientId:config.twitchClientId,clientSecret:config.twitchClientSecret,redirectUri:config.twitchRedirectUri,expectedLogin:config.channelLogin,tokenFile:config.twitchTokenFile}):null
const twitch=config.twitchEnabled?new LiveTwitchAdapter({clientId:config.twitchClientId,clientSecret:config.twitchClientSecret,tokenFile:config.twitchTokenFile,broadcasterId:config.broadcasterId,channelLogin:config.channelLogin,extensionSecret:config.extensionSecret,resurrectionRewardId:config.resurrectionRewardId}):new SyntheticTwitchAdapter(config.broadcasterId)
const sessions=new Map<string,{userId:string;expiresAt:number}>()
const clients=new Map<WebSocket,string|null>()
const publicDir=join(dirname(fileURLToPath(import.meta.url)),'../../stream-view/public')
function json(res:ServerResponse,status:number,body:unknown){const data=JSON.stringify(body);res.writeHead(status,{'content-type':'application/json','cache-control':'no-store'});res.end(data)}
function html(res:ServerResponse,status:number,title:string,message:string){const escape=(value:string)=>value.replace(/[&<>"']/g,character=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character]!));res.writeHead(status,{'content-type':'text/html; charset=utf-8','cache-control':'no-store','content-security-policy':"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'",'x-content-type-options':'nosniff'});res.end(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escape(title)}</title><body style="font:16px system-ui;max-width:48rem;margin:4rem auto;padding:0 1rem;background:#111;color:#eee"><h1>${escape(title)}</h1><p>${escape(message)}</p></body></html>`)}
async function body(req:IncomingMessage):Promise<unknown>{const chunks:Buffer[]=[];for await(const chunk of req)chunks.push(Buffer.from(chunk));if(chunks.reduce((n,b)=>n+b.length,0)>64_000)throw new Error('Request too large');return JSON.parse(Buffer.concat(chunks).toString('utf8')||'{}')}
function bearer(req:IncomingMessage):string|null{const h=req.headers.authorization;return h?.startsWith('Bearer ')?h.slice(7):null}
function sessionUser(token:string|null):string|null{if(!token)return null;const session=sessions.get(token);if(!session||session.expiresAt<=Date.now()){if(session)sessions.delete(token);return null}return session.userId}
@@ -25,7 +28,27 @@ function broadcast(){const sequence=game.snapshot().nextEventSequence-1;for(cons
export const server=createServer(async(req,res)=>{
try{
const url=new URL(req.url??'/',config.publicBaseUrl)
if(req.method==='GET'&&url.pathname==='/health')return json(res,200,{status:'ok',ready:twitch.ready,uptimeSeconds:Math.floor((Date.now()-startedAt)/1000),twitchMode:config.twitchEnabled?'configured':'synthetic'})
if(req.method==='GET'&&url.pathname==='/health')return json(res,200,{status:'ok',ready:twitch.ready,uptimeSeconds:Math.floor((Date.now()-startedAt)/1000),twitchMode:config.twitchEnabled?'configured':'synthetic',oauthConfigured:config.oauthConfigured})
if(req.method==='GET'&&url.pathname==='/oauth/status')return json(res,200,oauth?await oauth.status():{configured:false,authorized:false,redirectUri:config.twitchRedirectUri})
if(req.method==='GET'&&url.pathname==='/oauth/login'){
if(!oauth)return html(res,503,'Twitch OAuth is not configured','Configure the Twitch Client ID, Client Secret, and channel login on the server first.')
try{res.writeHead(302,{location:oauth.startAuthorization(),'cache-control':'no-store'});return res.end()}
catch(error){return html(res,429,'Twitch OAuth is temporarily unavailable',error instanceof Error?error.message:'Try again later.')}
}
if(req.method==='GET'&&url.pathname==='/oauth/callback'){
if(!oauth)return html(res,503,'Twitch OAuth is not configured','Configure Twitch OAuth on the server and try again.')
const oauthError=url.searchParams.get('error_description')??url.searchParams.get('error')
if(oauthError)return html(res,400,'Twitch authorization was declined',oauthError)
try{
const result=await oauth.completeAuthorization(url.searchParams.get('code')??'',url.searchParams.get('state')??'')
console.log(JSON.stringify({level:'info',component:'twitch-oauth',message:'Broadcaster authorization stored',userId:result.userId,login:result.login,scopes:result.scopes}))
return html(res,200,'Twungeon is authorized',`Authorized Twitch broadcaster ${result.login} (${result.userId}). You may close this page and continue setup.`)
}catch(error){
const message=error instanceof Error?error.message:'Twitch authorization failed.'
console.error(JSON.stringify({level:'error',component:'twitch-oauth',message}))
return html(res,400,'Twitch authorization failed',message)
}
}
if(req.method==='GET'&&url.pathname==='/api/state')return json(res,200,game.snapshot())
if(req.method==='POST'&&url.pathname==='/api/extension/session'){
const data=await body(req) as any,identity=await twitch.verifyExtensionToken(String(data.token??''))
@@ -33,6 +56,11 @@ export const server=createServer(async(req,res)=>{
const token=`session-${crypto.randomUUID()}`;sessions.set(token,{userId:identity.twitchUserId,expiresAt:Date.now()+15*60_000});game.bindExtension(identity.twitchUserId)
return json(res,200,{token,twitchUserId:identity.twitchUserId,state:game.personalizedSnapshot(identity.twitchUserId)})
}
if(req.method==='POST'&&url.pathname==='/api/extension/spawn'){
const userId=sessionUser(bearer(req));if(!userId)return json(res,401,{error:'UNAUTHENTICATED'})
const message=await twitch.createSpawn(userId,`extension-spawn-${crypto.randomUUID()}`);if(!message)return json(res,503,{error:'TWITCH_UNAVAILABLE'})
const result=game.spawn(message);if(result.accepted)broadcast();return json(res,result.accepted?200:409,{...result,state:game.personalizedSnapshot(userId)})
}
if(req.method==='POST'&&url.pathname==='/api/commands'){
const token=bearer(req),userId=sessionUser(token),parsed=CommandEnvelopeSchema.safeParse(await body(req))
if(!parsed.success)return json(res,400,{error:'INVALID_COMMAND',issues:parsed.error.issues.map(i=>({path:i.path,message:i.message}))})
@@ -40,9 +68,10 @@ export const server=createServer(async(req,res)=>{
}
if(req.method==='POST'&&url.pathname==='/api/dev/spawn'&&!config.twitchEnabled){const msg=await twitch.normalizeSpawn(await body(req));if(!msg)return json(res,400,{error:'INVALID_SPAWN'});const result=game.spawn(msg);if(result.accepted)broadcast();return json(res,result.accepted?200:409,result)}
if(req.method==='POST'&&url.pathname==='/api/dev/redemption'&&!config.twitchEnabled){const msg=await twitch.normalizeRedemption(await body(req));if(!msg)return json(res,400,{error:'INVALID_REDEMPTION'});const result=game.resurrect(msg);if(result.accepted)broadcast();return json(res,result.accepted?200:409,result)}
if(req.method==='GET'&&(url.pathname==='/'||url.pathname==='/extension')){const html=await readFile(join(publicDir,'index.html'));res.writeHead(200,{'content-type':'text/html; charset=utf-8'});return res.end(html)}
if(req.method==='GET'&&url.pathname==='/app.js'){const js=await readFile(join(publicDir,'app.js'));res.writeHead(200,{'content-type':'text/javascript; charset=utf-8'});return res.end(js)}
if(req.method==='GET'&&url.pathname==='/styles.css'){const css=await readFile(join(publicDir,'styles.css'));res.writeHead(200,{'content-type':'text/css; charset=utf-8'});return res.end(css)}
if(req.method==='GET'&&(url.pathname==='/'||url.pathname==='/extension')){const html=await readFile(join(publicDir,'index.html'));res.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store'});return res.end(html)}
if(req.method==='GET'&&url.pathname==='/privacy.html'){const html=await readFile(join(publicDir,'privacy.html'));res.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store'});return res.end(html)}
if(req.method==='GET'&&url.pathname==='/app.js'){const js=await readFile(join(publicDir,'app.js'));res.writeHead(200,{'content-type':'text/javascript; charset=utf-8','cache-control':'no-store'});return res.end(js)}
if(req.method==='GET'&&url.pathname==='/styles.css'){const css=await readFile(join(publicDir,'styles.css'));res.writeHead(200,{'content-type':'text/css; charset=utf-8','cache-control':'no-store'});return res.end(css)}
json(res,404,{error:'NOT_FOUND'})
}catch(error){console.error(JSON.stringify({level:'error',message:error instanceof Error?error.message:'Unexpected error'}));json(res,500,{error:'INTERNAL_ERROR'})}
})
+141
View File
@@ -0,0 +1,141 @@
import { randomBytes } from 'node:crypto'
import { z } from 'zod'
import {
REQUIRED_TWITCH_SCOPES,
storedTwitchTokenExists,
writeStoredTwitchToken
} from '../../../packages/twitch-adapter/src/tokenStore.js'
const TokenResponseSchema = z.object({
access_token: z.string().min(1),
refresh_token: z.string().min(1),
expires_in: z.number().int().nonnegative(),
scope: z.array(z.string()),
token_type: z.string()
})
const ValidationResponseSchema = z.object({
client_id: z.string().min(1),
login: z.string().min(1),
scopes: z.array(z.string()),
user_id: z.string().min(1),
expires_in: z.number().int().nonnegative()
})
export interface TwitchOAuthConfig {
clientId: string
clientSecret: string
redirectUri: string
expectedLogin: string
tokenFile: string
}
export interface TwitchOAuthResult {
login: string
userId: string
scopes: string[]
}
export interface TwitchOAuthStatus {
configured: boolean
authorized: boolean
redirectUri: string
}
export class TwitchOAuthService {
private readonly states = new Map<string, number>()
constructor(
private readonly config: TwitchOAuthConfig,
private readonly fetchImplementation: typeof fetch = fetch,
private readonly now: () => number = Date.now
) {}
startAuthorization(): string {
this.removeExpiredStates()
if (this.states.size >= 100) {
throw new Error('Too many OAuth attempts are pending. Wait ten minutes and try again.')
}
const state = randomBytes(32).toString('base64url')
this.states.set(state, this.now() + 10 * 60_000)
const url = new URL('https://id.twitch.tv/oauth2/authorize')
url.searchParams.set('client_id', this.config.clientId)
url.searchParams.set('redirect_uri', this.config.redirectUri)
url.searchParams.set('response_type', 'code')
url.searchParams.set('scope', REQUIRED_TWITCH_SCOPES.join(' '))
url.searchParams.set('state', state)
return url.toString()
}
async completeAuthorization(code: string, state: string): Promise<TwitchOAuthResult> {
this.removeExpiredStates()
const expiresAt = this.states.get(state)
this.states.delete(state)
if (!expiresAt || expiresAt <= this.now()) {
throw new Error('The OAuth state is missing or expired. Start authorization again.')
}
if (!code) throw new Error('Twitch did not return an authorization code.')
const tokenResponse = await this.fetchImplementation('https://id.twitch.tv/oauth2/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: this.config.redirectUri
})
})
if (!tokenResponse.ok) {
throw new Error(`Twitch token exchange failed with HTTP ${tokenResponse.status}.`)
}
const token = TokenResponseSchema.parse(await tokenResponse.json())
const validationResponse = await this.fetchImplementation('https://id.twitch.tv/oauth2/validate', {
headers: { authorization: `OAuth ${token.access_token}` }
})
if (!validationResponse.ok) {
throw new Error(`Twitch token validation failed with HTTP ${validationResponse.status}.`)
}
const validation = ValidationResponseSchema.parse(await validationResponse.json())
if (validation.client_id !== this.config.clientId) {
throw new Error('Twitch returned a token for a different application.')
}
if (validation.login.toLowerCase() !== this.config.expectedLogin.toLowerCase()) {
throw new Error(`Authorize with the configured broadcaster account: ${this.config.expectedLogin}.`)
}
const missingScopes = REQUIRED_TWITCH_SCOPES.filter(scope => !validation.scopes.includes(scope))
if (missingScopes.length) {
throw new Error(`The Twitch token is missing required scopes: ${missingScopes.join(', ')}.`)
}
await writeStoredTwitchToken(this.config.tokenFile, {
accessToken: token.access_token,
refreshToken: token.refresh_token,
scope: validation.scopes,
expiresIn: token.expires_in,
obtainmentTimestamp: this.now(),
userId: validation.user_id,
login: validation.login,
clientId: validation.client_id
})
return { login: validation.login, userId: validation.user_id, scopes: validation.scopes }
}
async status(): Promise<TwitchOAuthStatus> {
return {
configured: true,
authorized: await storedTwitchTokenExists(this.config.tokenFile),
redirectUri: this.config.redirectUri
}
}
private removeExpiredStates(): void {
const now = this.now()
for (const [state, expiresAt] of this.states) {
if (expiresAt <= now) this.states.delete(state)
}
}
}