import type { ChannelPointRedemption, SpawnMessage } from '../../contracts/src/index.js' import { ApiClient } from '@twurple/api' import { StaticAuthProvider } from '@twurple/auth' import { ChatClient } from '@twurple/chat' import { EventSubWsListener } from '@twurple/eventsub-ws' import { jwtVerify } from 'jose' export interface TwitchIdentity { twitchUserId: string; displayName: string } export interface TwitchAdapter { readonly ready: boolean verifyExtensionToken(token: string): Promise normalizeSpawn(input: unknown): Promise normalizeRedemption(input: unknown): Promise } /** Local acceptance adapter. Production Twurple wiring implements this boundary. */ export class SyntheticTwitchAdapter implements TwitchAdapter { readonly ready = true constructor(private readonly broadcasterId: string) {} async verifyExtensionToken(token: string): Promise { const match=/^dev:([^:]+):(.+)$/.exec(token) return match ? {twitchUserId:match[1]!,displayName:match[2]!} : null } async normalizeSpawn(input: any): Promise { if(!input || input.command!=='!spawn' || typeof input.twitchUserId!=='string')return null return {type:'spawn-requested',externalEventId:String(input.externalEventId),twitchUserId:input.twitchUserId,displayName:String(input.displayName??'Adventurer'),broadcasterId:this.broadcasterId,followerVerified:input.followerVerified===true} } async normalizeRedemption(input:any):Promise{ if(!input || typeof input.twitchUserId!=='string' || typeof input.rewardId!=='string')return null return {type:'channel-point-resurrection-redeemed',externalEventId:String(input.externalEventId),twitchUserId:input.twitchUserId,rewardId:input.rewardId} } } export interface LiveTwitchConfig { clientId:string; accessToken:string; broadcasterId:string; channelLogin:string; extensionSecret:string; resurrectionRewardId:string } export interface TwitchHandlers { onSpawn(message:SpawnMessage):void; onRedemption(message:ChannelPointRedemption):void; onConnection(ready:boolean):void } /** Twurple production adapter. All callbacks are normalized and safe for domain use. */ export class LiveTwitchAdapter implements TwitchAdapter { private readonly api:ApiClient private readonly chat:ChatClient private readonly eventSub:EventSubWsListener private connected=false constructor(private readonly config:LiveTwitchConfig){ const authProvider=new StaticAuthProvider(config.clientId,config.accessToken,['chat:read','moderator:read:followers','channel:read:redemptions']) this.api=new ApiClient({authProvider}) this.chat=new ChatClient({authProvider,channels:[config.channelLogin],readOnly:true,rejoinChannelsOnReconnect:true}) this.eventSub=new EventSubWsListener({apiClient:this.api}) } get ready(){return this.connected} async start(handlers:TwitchHandlers):Promise{ this.chat.onConnect(()=>{this.connected=true;handlers.onConnection(true)}) this.chat.onDisconnect(()=>{this.connected=false;handlers.onConnection(false)}) this.chat.onMessage(async(_channel,_user,text,msg)=>{ try{ if(text.trim()==='!spawn'){ const follower=await this.api.channels.getChannelFollowers(this.config.broadcasterId,msg.userInfo.userId,{limit:1}) handlers.onSpawn({type:'spawn-requested',externalEventId:msg.id,twitchUserId:msg.userInfo.userId,displayName:msg.userInfo.displayName,broadcasterId:this.config.broadcasterId,followerVerified:follower.data.length===1}) } }catch(error){console.error(JSON.stringify({level:'error',component:'twitch-adapter',message:error instanceof Error?error.message:'Twitch event failed'}))} }) this.eventSub.onChannelRedemptionAddForReward(this.config.broadcasterId,this.config.resurrectionRewardId,event=>handlers.onRedemption({type:'channel-point-resurrection-redeemed',externalEventId:event.id,twitchUserId:event.userId,rewardId:event.rewardId})) this.eventSub.start() await this.chat.connect() } async verifyExtensionToken(token:string):Promise{ try{ const secret=Buffer.from(this.config.extensionSecret,'base64') const {payload}=await jwtVerify(token,secret,{algorithms:['HS256']}) if(payload.channel_id!==this.config.broadcasterId || typeof payload.user_id!=='string' || payload.role==='external')return null const user=await this.api.users.getUserById(payload.user_id) return {twitchUserId:payload.user_id,displayName:user?.displayName??payload.user_id} }catch{return null} } async normalizeSpawn():Promise{return null} async normalizeRedemption():Promise{return null} }