Merge pull request #87 from subculture-collective/feat/twitch-bot
feat(twitch): wire tmi.js IRC bot and connect to court API
This commit was merged in pull request #87.
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
||||
RedemptionRateLimiter,
|
||||
DEFAULT_REDEMPTION_RATE_LIMIT,
|
||||
} from './twitch/eventsub.js';
|
||||
import { initTwitchBot } from './twitch/bot.js';
|
||||
import {
|
||||
elapsedSecondsSince,
|
||||
instrumentCourtSessionStore,
|
||||
@@ -1007,6 +1008,34 @@ export async function createServerApp(
|
||||
replay,
|
||||
});
|
||||
|
||||
// Start Twitch bot (noop if credentials absent)
|
||||
const twitchBot = initTwitchBot({
|
||||
channel: process.env.TWITCH_CHANNEL ?? '',
|
||||
botToken: process.env.TWITCH_BOT_TOKEN ?? '',
|
||||
clientId: process.env.TWITCH_CLIENT_ID ?? '',
|
||||
clientSecret: process.env.TWITCH_CLIENT_SECRET ?? '',
|
||||
apiBaseUrl: `http://localhost:${process.env.PORT ?? 3000}`,
|
||||
getActiveSessionId: (() => {
|
||||
let cachedId: string | null = null;
|
||||
let cacheExpiresAt = 0;
|
||||
return async () => {
|
||||
const now = Date.now();
|
||||
if (now < cacheExpiresAt) return cachedId;
|
||||
const sessions = await store.listSessions();
|
||||
const running = sessions.find(s => s.status === 'running');
|
||||
cachedId = running?.id ?? null;
|
||||
cacheExpiresAt = now + 5_000; // cache for 5 sec; commands to a just-ended session fail gracefully
|
||||
return cachedId;
|
||||
};
|
||||
})(),
|
||||
});
|
||||
|
||||
twitchBot.start().catch(err => {
|
||||
logger.warn(
|
||||
`[Twitch Bot] Failed to start: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
});
|
||||
|
||||
registerStaticAndSpaRoutes(app, {
|
||||
publicDir,
|
||||
dashboardDir,
|
||||
|
||||
137
src/twitch/bot.test.ts
Normal file
137
src/twitch/bot.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { TwitchBot } from './bot.js';
|
||||
|
||||
describe('TwitchBot.parseCommand', () => {
|
||||
// Instantiate without credentials → noop mode, but parseCommand still works
|
||||
const bot = new TwitchBot();
|
||||
|
||||
it('parses !press command', () => {
|
||||
const result = bot.parseCommand('!press 2', 'viewer1');
|
||||
assert.ok(result, 'should return a command');
|
||||
assert.equal(result.action, 'press');
|
||||
assert.equal(result.params.statementNumber, 2);
|
||||
assert.equal(result.username, 'viewer1');
|
||||
});
|
||||
|
||||
it('parses !present command', () => {
|
||||
const result = bot.parseCommand('!present banana', 'viewer2');
|
||||
assert.ok(result, 'should return a command');
|
||||
assert.equal(result.action, 'present');
|
||||
assert.equal(result.params.evidenceId, 'banana');
|
||||
});
|
||||
|
||||
it('returns null for unknown command', () => {
|
||||
const result = bot.parseCommand('!unknown', 'viewer3');
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
it('returns null for non-command message', () => {
|
||||
const result = bot.parseCommand('hello world', 'viewer4');
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
it('rate-limits duplicate commands from same user', () => {
|
||||
// First command allowed
|
||||
const first = bot.parseCommand('!press 1', 'spammer');
|
||||
assert.ok(first);
|
||||
// Same command within duplicate window → blocked
|
||||
const second = bot.parseCommand('!press 1', 'spammer');
|
||||
assert.equal(second, null, 'duplicate should be rate-limited');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TwitchBot.forwardCommand routing', () => {
|
||||
const requests: Array<{ url: string; body: unknown }> = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
// Save original env var values so we can restore them (not just delete them)
|
||||
let origChannel: string | undefined;
|
||||
let origBotToken: string | undefined;
|
||||
let origClientId: string | undefined;
|
||||
let origClientSecret: string | undefined;
|
||||
|
||||
before(() => {
|
||||
origChannel = process.env.TWITCH_CHANNEL;
|
||||
origBotToken = process.env.TWITCH_BOT_TOKEN;
|
||||
origClientId = process.env.TWITCH_CLIENT_ID;
|
||||
origClientSecret = process.env.TWITCH_CLIENT_SECRET;
|
||||
|
||||
// Set env vars so credential check passes when constructing with config
|
||||
process.env.TWITCH_CHANNEL = 'test';
|
||||
process.env.TWITCH_BOT_TOKEN = 'oauth:test';
|
||||
process.env.TWITCH_CLIENT_ID = 'cid';
|
||||
process.env.TWITCH_CLIENT_SECRET = 'csec';
|
||||
|
||||
// Replace fetch with a recorder
|
||||
globalThis.fetch = async (url: string | Request | URL, init?: RequestInit) => {
|
||||
requests.push({
|
||||
url: String(url),
|
||||
body: JSON.parse((init?.body as string) ?? '{}'),
|
||||
});
|
||||
return { ok: true, status: 200, json: async () => ({}) } as Response;
|
||||
};
|
||||
});
|
||||
|
||||
after(() => {
|
||||
// Restore original env var values
|
||||
if (origChannel === undefined) delete process.env.TWITCH_CHANNEL;
|
||||
else process.env.TWITCH_CHANNEL = origChannel;
|
||||
|
||||
if (origBotToken === undefined) delete process.env.TWITCH_BOT_TOKEN;
|
||||
else process.env.TWITCH_BOT_TOKEN = origBotToken;
|
||||
|
||||
if (origClientId === undefined) delete process.env.TWITCH_CLIENT_ID;
|
||||
else process.env.TWITCH_CLIENT_ID = origClientId;
|
||||
|
||||
if (origClientSecret === undefined) delete process.env.TWITCH_CLIENT_SECRET;
|
||||
else process.env.TWITCH_CLIENT_SECRET = origClientSecret;
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function makeBot(): TwitchBot {
|
||||
return new TwitchBot({
|
||||
channel: 'test',
|
||||
botToken: 'oauth:test',
|
||||
clientId: 'cid',
|
||||
clientSecret: 'csec',
|
||||
apiBaseUrl: 'http://localhost:3000',
|
||||
getActiveSessionId: async () => 'session-abc',
|
||||
});
|
||||
}
|
||||
|
||||
it('!press routes to /press with statementNumber', async () => {
|
||||
requests.length = 0;
|
||||
const bot = makeBot();
|
||||
const cmd = bot.parseCommand('!press 3', 'viewer1');
|
||||
assert.ok(cmd);
|
||||
await (bot as any).forwardCommand(cmd, 'session-abc');
|
||||
assert.equal(requests.length, 1);
|
||||
assert.ok(requests[0].url.includes('/api/court/sessions/session-abc/press'));
|
||||
assert.equal((requests[0].body as any).statementNumber, 3);
|
||||
});
|
||||
|
||||
it('!present routes to /present with evidenceId', async () => {
|
||||
requests.length = 0;
|
||||
const bot = makeBot();
|
||||
const cmd = bot.parseCommand('!present banana 2', 'viewer2');
|
||||
assert.ok(cmd);
|
||||
await (bot as any).forwardCommand(cmd, 'session-abc');
|
||||
assert.equal(requests.length, 1);
|
||||
assert.ok(requests[0].url.includes('/api/court/sessions/session-abc/present'));
|
||||
assert.equal((requests[0].body as any).evidenceId, 'banana');
|
||||
});
|
||||
|
||||
it('!vote routes to /vote with voteType verdict', async () => {
|
||||
requests.length = 0;
|
||||
const bot = makeBot();
|
||||
const cmd = bot.parseCommand('!vote guilty', 'viewer3');
|
||||
assert.ok(cmd);
|
||||
await (bot as any).forwardCommand(cmd, 'session-abc');
|
||||
assert.equal(requests.length, 1);
|
||||
assert.ok(requests[0].url.includes('/api/court/sessions/session-abc/vote'));
|
||||
assert.equal((requests[0].body as any).voteType, 'verdict');
|
||||
assert.equal((requests[0].body as any).choice, 'guilty');
|
||||
});
|
||||
});
|
||||
@@ -6,24 +6,30 @@
|
||||
*/
|
||||
|
||||
import type { EventEmitter } from 'events';
|
||||
import { Client as TmiClient, type ChatUserstate } from 'tmi.js';
|
||||
import {
|
||||
CommandRateLimiter,
|
||||
DEFAULT_COMMAND_RATE_LIMIT,
|
||||
} from './command-rate-limit.js';
|
||||
import { parseCommand as parseChatCommand } from './commands.js';
|
||||
|
||||
export interface BotConfig {
|
||||
channel: string;
|
||||
/** Bot account username; defaults to channel name when omitted. */
|
||||
botUsername?: string;
|
||||
botToken: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
apiBaseUrl: string;
|
||||
/** Returns the current active session ID, or null if no session is running. */
|
||||
getActiveSessionId: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
export interface ParsedCommand {
|
||||
action: 'press' | 'present' | 'vote' | 'sentence';
|
||||
username: string;
|
||||
timestamp: number;
|
||||
params?: Record<string, any>;
|
||||
params: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface RedemptionEvent {
|
||||
@@ -42,6 +48,7 @@ export class TwitchBot {
|
||||
private isActive: boolean = false;
|
||||
private eventEmitter: EventEmitter | null = null;
|
||||
private commandRateLimiter: CommandRateLimiter;
|
||||
private tmiClient: TmiClient | null = null;
|
||||
|
||||
constructor(config?: BotConfig) {
|
||||
// Initialize rate limiter regardless of config
|
||||
@@ -75,7 +82,7 @@ export class TwitchBot {
|
||||
* Initialize bot: connect to IRC and register EventSub
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
if (!this.config || !this.isActive) {
|
||||
if (!this.config || this.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -100,9 +107,50 @@ export class TwitchBot {
|
||||
* Stub implementation — will use tmi.js
|
||||
*/
|
||||
private async connectIRC(): Promise<void> {
|
||||
// Will be implemented with tmi.js
|
||||
// For now, stub
|
||||
console.log('[Twitch Bot] IRC connection stub');
|
||||
if (!this.config) return;
|
||||
|
||||
const identityUsername = this.config.botUsername ?? this.config.channel;
|
||||
|
||||
this.tmiClient = new TmiClient({
|
||||
identity: {
|
||||
username: identityUsername,
|
||||
password: this.config.botToken,
|
||||
},
|
||||
channels: [this.config.channel],
|
||||
});
|
||||
|
||||
this.tmiClient.on(
|
||||
'message',
|
||||
async (
|
||||
_channel: string,
|
||||
tags: ChatUserstate,
|
||||
message: string,
|
||||
self: boolean,
|
||||
) => {
|
||||
try {
|
||||
// Ignore messages sent by the bot itself to avoid feedback loops
|
||||
if (self) return;
|
||||
|
||||
const username =
|
||||
tags.username ?? tags['display-name'] ?? 'unknown';
|
||||
const command = this.parseCommand(message, username);
|
||||
if (!command || !this.config) return;
|
||||
|
||||
const sessionId = await this.config.getActiveSessionId();
|
||||
if (!sessionId) return;
|
||||
|
||||
await this.forwardCommand(command, sessionId);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[Twitch Bot] Error handling IRC message:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await this.tmiClient.connect();
|
||||
console.log(`[Twitch Bot] IRC connected to #${this.config.channel}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,19 +171,16 @@ export class TwitchBot {
|
||||
message: string,
|
||||
username: string,
|
||||
): ParsedCommand | null {
|
||||
// Check rate limit first
|
||||
const rateLimitCheck = this.commandRateLimiter.check(username, message);
|
||||
if (!rateLimitCheck.allowed) {
|
||||
console.warn(
|
||||
`[Twitch Bot] Command rate limited for ${username}: ${rateLimitCheck.reason}`,
|
||||
`[Twitch Bot] Rate limited ${username}: ${rateLimitCheck.reason}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Will delegate to commands.ts parser
|
||||
// For now, stub
|
||||
console.log(`[Twitch Bot] Parsed command from ${username}: ${message}`);
|
||||
return null;
|
||||
const parsed = parseChatCommand(message, username);
|
||||
return parsed as ParsedCommand | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,6 +200,59 @@ export class TwitchBot {
|
||||
);
|
||||
}
|
||||
|
||||
private async forwardCommand(
|
||||
command: ParsedCommand,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
if (!this.config) return;
|
||||
|
||||
let path: string;
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
if (command.action === 'press') {
|
||||
path = `/api/court/sessions/${sessionId}/press`;
|
||||
body = { statementNumber: command.params?.statementNumber };
|
||||
} else if (command.action === 'present') {
|
||||
path = `/api/court/sessions/${sessionId}/present`;
|
||||
body = {
|
||||
evidenceId: command.params?.evidenceId,
|
||||
statementNumber: command.params?.statementNumber,
|
||||
};
|
||||
} else if (command.action === 'vote') {
|
||||
path = `/api/court/sessions/${sessionId}/vote`;
|
||||
body = {
|
||||
voteType: 'verdict',
|
||||
choice: command.params?.choice,
|
||||
username: command.username,
|
||||
};
|
||||
} else if (command.action === 'sentence') {
|
||||
path = `/api/court/sessions/${sessionId}/vote`;
|
||||
body = {
|
||||
voteType: 'sentence',
|
||||
choice: command.params?.choice,
|
||||
username: command.username,
|
||||
};
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${this.config.apiBaseUrl}${path}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(
|
||||
`[Twitch Bot] API error ${res.status} for ${command.action} from ${command.username}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Twitch Bot] Failed to forward command:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop bot and disconnect
|
||||
*/
|
||||
@@ -165,7 +263,12 @@ export class TwitchBot {
|
||||
|
||||
console.log('[Twitch Bot] Stopping bot');
|
||||
this.isActive = false;
|
||||
// Cleanup: disconnect IRC, unregister EventSub
|
||||
|
||||
if (this.tmiClient) {
|
||||
this.tmiClient.removeAllListeners();
|
||||
await this.tmiClient.disconnect().catch(() => {});
|
||||
this.tmiClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
public isRunning(): boolean {
|
||||
@@ -193,6 +296,7 @@ export function initTwitchBot(config?: BotConfig): TwitchBot {
|
||||
clientId: process.env.TWITCH_CLIENT_ID || '',
|
||||
clientSecret: process.env.TWITCH_CLIENT_SECRET || '',
|
||||
apiBaseUrl: process.env.API_BASE_URL || 'http://localhost:3000',
|
||||
getActiveSessionId: async () => null,
|
||||
};
|
||||
|
||||
globalBot = new TwitchBot(finalConfig);
|
||||
|
||||
34
src/twitch/tmi.d.ts
vendored
Normal file
34
src/twitch/tmi.d.ts
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
// tmi.js v1.8.5 ships no TypeScript declarations and @types/tmi.js is not available.
|
||||
// This minimal declaration provides the types needed by bot.ts.
|
||||
declare module 'tmi.js' {
|
||||
interface ChatUserstate {
|
||||
username?: string;
|
||||
'display-name'?: string;
|
||||
[key: string]: string | boolean | undefined;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
identity?: {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
channels?: string[];
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
class Client {
|
||||
constructor(opts: Options);
|
||||
on(event: 'message', listener: (channel: string, tags: ChatUserstate, message: string, self: boolean) => void): this;
|
||||
on(event: string, listener: (...args: unknown[]) => void): this;
|
||||
connect(): Promise<[string, number]>;
|
||||
disconnect(): Promise<[string, number]>;
|
||||
removeAllListeners(event?: string): this;
|
||||
}
|
||||
|
||||
const tmi: {
|
||||
Client: typeof Client;
|
||||
};
|
||||
|
||||
export { Client, ChatUserstate, Options };
|
||||
export default tmi;
|
||||
}
|
||||
Reference in New Issue
Block a user