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
+77 -20
View File
@@ -1,14 +1,18 @@
import type { ChannelPointRedemption, SpawnMessage } from '../../contracts/src/index.js'
import { ApiClient } from '@twurple/api'
import { StaticAuthProvider } from '@twurple/auth'
import { RefreshingAuthProvider } from '@twurple/auth'
import { ChatClient } from '@twurple/chat'
import { EventSubWsListener } from '@twurple/eventsub-ws'
import { jwtVerify } from 'jose'
import { readStoredTwitchToken, writeStoredTwitchToken } from './tokenStore.js'
export { REQUIRED_TWITCH_SCOPES } from './tokenStore.js'
export interface TwitchIdentity { twitchUserId: string; displayName: string }
export interface TwitchAdapter {
readonly ready: boolean
verifyExtensionToken(token: string): Promise<TwitchIdentity | null>
createSpawn(twitchUserId: string, externalEventId: string): Promise<SpawnMessage | null>
normalizeSpawn(input: unknown): Promise<SpawnMessage | null>
normalizeRedemption(input: unknown): Promise<ChannelPointRedemption | null>
}
@@ -21,6 +25,7 @@ export class SyntheticTwitchAdapter implements TwitchAdapter {
const match=/^dev:([^:]+):(.+)$/.exec(token)
return match ? {twitchUserId:match[1]!,displayName:match[2]!} : null
}
async createSpawn(twitchUserId:string,externalEventId:string):Promise<SpawnMessage>{return {type:'spawn-requested',externalEventId,twitchUserId,displayName:twitchUserId,broadcasterId:this.broadcasterId,followerVerified:true}}
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}
@@ -31,46 +36,98 @@ export class SyntheticTwitchAdapter implements TwitchAdapter {
}
}
export interface LiveTwitchConfig { clientId:string; accessToken:string; broadcasterId:string; channelLogin:string; extensionSecret:string; resurrectionRewardId:string }
export interface LiveTwitchConfig {
clientId: string
clientSecret: string
tokenFile: 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 api: ApiClient | null = null
private chat: ChatClient | null = null
private eventSub: EventSubWsListener | null = null
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})
}
constructor(private readonly config:LiveTwitchConfig) {}
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)=>{
const storedToken = await readStoredTwitchToken(this.config.tokenFile)
if (storedToken.clientId !== this.config.clientId) {
throw new Error('The stored Twitch token belongs to a different application.')
}
if (storedToken.userId !== this.config.broadcasterId) {
throw new Error('The stored Twitch token does not belong to TWITCH_BROADCASTER_ID.')
}
const authProvider = new RefreshingAuthProvider({
clientId: this.config.clientId,
clientSecret: this.config.clientSecret
})
authProvider.onRefresh((userId, token) => {
void writeStoredTwitchToken(this.config.tokenFile, {
...token,
refreshToken: token.refreshToken ?? storedToken.refreshToken,
userId,
login: storedToken.login,
clientId: this.config.clientId
}).catch(error => console.error(JSON.stringify({
level: 'error',
component: 'twitch-token-store',
message: error instanceof Error ? error.message : 'Token persistence failed'
})))
})
authProvider.addUser(storedToken.userId, storedToken, ['chat'])
const api = this.api = new ApiClient({ authProvider })
const chat = this.chat = new ChatClient({
authProvider,
channels: [this.config.channelLogin],
readOnly: true,
rejoinChannelsOnReconnect: true
})
const eventSub = this.eventSub = new EventSubWsListener({ apiClient: api })
chat.onConnect(()=>{this.connected=true;handlers.onConnection(true)})
chat.onDisconnect(()=>{this.connected=false;handlers.onConnection(false)})
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})
const followerVerified=await this.isEligibleFollower(msg.userInfo.userId)
handlers.onSpawn({type:'spawn-requested',externalEventId:msg.id,twitchUserId:msg.userInfo.userId,displayName:msg.userInfo.displayName,broadcasterId:this.config.broadcasterId,followerVerified})
}
}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()
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}))
eventSub.start()
await chat.connect()
}
async verifyExtensionToken(token:string):Promise<TwitchIdentity|null>{
try{
if (!this.api) return null
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
if(payload.channel_id!==this.config.broadcasterId || typeof payload.user_id!=='string' || typeof payload.exp!=='number' || 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 createSpawn(twitchUserId:string,externalEventId:string):Promise<SpawnMessage|null>{
if(!this.api)return null
const user=await this.api.users.getUserById(twitchUserId)
if(!user)return null
return {type:'spawn-requested',externalEventId,twitchUserId,displayName:user.displayName,broadcasterId:this.config.broadcasterId,followerVerified:await this.isEligibleFollower(twitchUserId)}
}
private async isEligibleFollower(twitchUserId:string):Promise<boolean>{
if(twitchUserId===this.config.broadcasterId)return true
if(!this.api)return false
const follower=await this.api.channels.getChannelFollowers(this.config.broadcasterId,twitchUserId,{limit:1})
return follower.data.length===1
}
async normalizeSpawn():Promise<SpawnMessage|null>{return null}
async normalizeRedemption():Promise<ChannelPointRedemption|null>{return null}
}
+52
View File
@@ -0,0 +1,52 @@
import { randomUUID } from 'node:crypto'
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { z } from 'zod'
export const REQUIRED_TWITCH_SCOPES = [
'chat:read',
'moderator:read:followers',
'channel:read:redemptions'
] as const
const StoredTwitchTokenSchema = z.object({
accessToken: z.string().min(1),
refreshToken: z.string().min(1),
scope: z.array(z.string()),
expiresIn: z.number().int().nonnegative().nullable(),
obtainmentTimestamp: z.number().int().nonnegative(),
userId: z.string().min(1),
login: z.string().min(1),
clientId: z.string().min(1)
})
export type StoredTwitchToken = z.infer<typeof StoredTwitchTokenSchema>
export async function readStoredTwitchToken(path: string): Promise<StoredTwitchToken> {
const value: unknown = JSON.parse(await readFile(path, 'utf8'))
return StoredTwitchTokenSchema.parse(value)
}
export async function storedTwitchTokenExists(path: string): Promise<boolean> {
try {
await readStoredTwitchToken(path)
return true
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
throw error
}
}
export async function writeStoredTwitchToken(path: string, token: StoredTwitchToken): Promise<void> {
const validated = StoredTwitchTokenSchema.parse(token)
const directory = dirname(path)
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`
await mkdir(directory, { recursive: true, mode: 0o700 })
await writeFile(temporaryPath, `${JSON.stringify(validated, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600,
flag: 'wx'
})
await rename(temporaryPath, path)
await chmod(path, 0o600)
}