feat: connect Twungeon to live Twitch
This commit is contained in:
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user