Files
Twungeon/packages/twitch-adapter/src/index.ts
T

77 lines
4.7 KiB
TypeScript

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<TwitchIdentity | null>
normalizeSpawn(input: unknown): Promise<SpawnMessage | null>
normalizeRedemption(input: unknown): Promise<ChannelPointRedemption | null>
}
/** 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<TwitchIdentity|null> {
const match=/^dev:([^:]+):(.+)$/.exec(token)
return match ? {twitchUserId:match[1]!,displayName:match[2]!} : null
}
async normalizeSpawn(input: any): Promise<SpawnMessage|null> {
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<ChannelPointRedemption|null>{
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<void>{
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<TwitchIdentity|null>{
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<SpawnMessage|null>{return null}
async normalizeRedemption():Promise<ChannelPointRedemption|null>{return null}
}