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:
Patrick Fanella
2026-03-01 10:59:09 -06:00
committed by GitHub
4 changed files with 316 additions and 12 deletions

View File

@@ -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
View 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');
});
});

View File

@@ -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
View 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;
}