feat: connect Twungeon to live Twitch
This commit is contained in:
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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'})}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user