feat(twitch): wire tmi.js IRC bot and connect to court API #87

Merged
onnwee merged 9 commits from feat/twitch-bot into main 2026-03-01 10:59:09 -06:00
onnwee commented 2026-03-01 10:27:10 -06:00 (Migrated from github.com)

Summary

  • Wires commands.ts parser into TwitchBot.parseCommand (was a stub returning null) — adds src/twitch/bot.test.ts with 5 unit tests
  • Implements real connectIRC() using tmi.js: creates tmi.Client, listens to chat messages, resolves active session ID per-message, and calls forwardCommand
  • Adds forwardCommand() private method routing !press/press, !present/present, !vote/!sentence/vote with correct body shapes
  • Initialises the bot in server.ts startup (noop when env vars absent, logs "Twitch bot disabled: missing credentials")
  • Adds src/twitch/tmi.d.ts minimal type declarations (tmi.js 1.8.5 ships no TS types)
  • Adds 3 routing tests for forwardCommand path/body correctness using a mocked globalThis.fetch

Test Plan

  • npm test — 179 pass, 0 fail
  • npm run lint passes
  • Server starts without Twitch env vars → logs "Twitch bot disabled: missing credentials"
  • With Twitch credentials set: server connects to IRC, !press 2 in chat → POST /api/court/sessions/:id/press
  • Check session metadata — pressVotes updates after !press

Closes #77

Note: Orchestrator integration (reading pressVotes/presentVotes at decision points) is blocked by #76 and is out of scope for this PR.

🤖 Generated with Claude Code

## Summary - Wires `commands.ts` parser into `TwitchBot.parseCommand` (was a stub returning null) — adds `src/twitch/bot.test.ts` with 5 unit tests - Implements real `connectIRC()` using tmi.js: creates `tmi.Client`, listens to chat messages, resolves active session ID per-message, and calls `forwardCommand` - Adds `forwardCommand()` private method routing `!press` → `/press`, `!present` → `/present`, `!vote`/`!sentence` → `/vote` with correct body shapes - Initialises the bot in `server.ts` startup (noop when env vars absent, logs "Twitch bot disabled: missing credentials") - Adds `src/twitch/tmi.d.ts` minimal type declarations (tmi.js 1.8.5 ships no TS types) - Adds 3 routing tests for `forwardCommand` path/body correctness using a mocked `globalThis.fetch` ## Test Plan - [ ] `npm test` — 179 pass, 0 fail - [ ] `npm run lint` passes - [ ] Server starts without Twitch env vars → logs "Twitch bot disabled: missing credentials" - [ ] With Twitch credentials set: server connects to IRC, `!press 2` in chat → `POST /api/court/sessions/:id/press` - [ ] Check session metadata — `pressVotes` updates after `!press` Closes #77 > Note: Orchestrator integration (reading pressVotes/presentVotes at decision points) is blocked by #76 and is out of scope for this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
copilot-pull-request-reviewer[bot] (Migrated from github.com) reviewed 2026-03-01 10:31:56 -06:00
copilot-pull-request-reviewer[bot] (Migrated from github.com) left a comment

Pull request overview

This PR adds a Twitch chat bot integration that parses !press/!present/!vote/!sentence commands and forwards them to the court API, and wires bot startup into the server lifecycle.

Changes:

  • Added a minimal tmi.js TypeScript declaration file and implemented real IRC connectivity via tmi.js.
  • Wired the shared command parser into TwitchBot.parseCommand, added unit tests for parsing and API routing.
  • Initialized the Twitch bot during server app creation with a getActiveSessionId() resolver.

Reviewed changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated 5 comments.

File Description
src/twitch/tmi.d.ts Adds minimal TS typings for tmi.js usage in the bot.
src/twitch/bot.ts Implements IRC client wiring, command parsing delegation, and command forwarding to the court API.
src/twitch/bot.test.ts Adds unit tests for parsing + forwarding route/body shapes using mocked fetch.
src/server.ts Starts the Twitch bot on server startup and resolves the active session ID from the session store.
Comments suppressed due to low confidence (1)

src/server.ts:1057

  • Starting the Twitch bot inside createServerApp introduces process-wide side effects for unit/integration tests (many tests call createServerApp), and the returned dispose() currently does not stop/disconnect the bot. This can lead to unexpected outbound Twitch connections on developer machines with Twitch env vars set and can leak resources across tests. Consider moving bot startup to bootstrap() (after listen()), or gating it behind an explicit flag, and make sure dispose() stops/destroys the global bot instance.
    // 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: async () => {
            const sessions = await store.listSessions();
            const running = sessions.find(s => s.status === 'running');
            return running?.id ?? null;
        },
    });

    twitchBot.start().catch(err => {
        logger.warn(
            `[Twitch Bot] Failed to start: ${err instanceof Error ? err.message : String(err)}`,
        );
    });

    registerStaticAndSpaRoutes(app, {
        publicDir,
        dashboardDir,
    });

    const restartPendingIds = await store.recoverInterruptedSessions();
    if (autoRunCourtSession) {
        for (const sessionId of restartPendingIds) {
            try {
                await recorder.start({ sessionId });
            } catch (error) {
                logger.warn(
                    `[replay] failed to start recorder for recovered session=${sessionId}: ${error instanceof Error ? error.message : String(error)}`,
                );
            }
            void runCourtSession(sessionId, store);
        }
    }

    return {
        app,
        store,
        dispose: () => {
            clearInterval(pruneTimer);
            void recorder.dispose();
        },
    };

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

## Pull request overview This PR adds a Twitch chat bot integration that parses `!press/!present/!vote/!sentence` commands and forwards them to the court API, and wires bot startup into the server lifecycle. **Changes:** - Added a minimal `tmi.js` TypeScript declaration file and implemented real IRC connectivity via `tmi.js`. - Wired the shared command parser into `TwitchBot.parseCommand`, added unit tests for parsing and API routing. - Initialized the Twitch bot during server app creation with a `getActiveSessionId()` resolver. ### Reviewed changes Copilot reviewed 3 out of 4 changed files in this pull request and generated 5 comments. | File | Description | | ---- | ----------- | | src/twitch/tmi.d.ts | Adds minimal TS typings for `tmi.js` usage in the bot. | | src/twitch/bot.ts | Implements IRC client wiring, command parsing delegation, and command forwarding to the court API. | | src/twitch/bot.test.ts | Adds unit tests for parsing + forwarding route/body shapes using mocked `fetch`. | | src/server.ts | Starts the Twitch bot on server startup and resolves the active session ID from the session store. | <details> <summary>Comments suppressed due to low confidence (1)</summary> **src/server.ts:1057** * Starting the Twitch bot inside `createServerApp` introduces process-wide side effects for unit/integration tests (many tests call `createServerApp`), and the returned `dispose()` currently does not stop/disconnect the bot. This can lead to unexpected outbound Twitch connections on developer machines with Twitch env vars set and can leak resources across tests. Consider moving bot startup to `bootstrap()` (after `listen()`), or gating it behind an explicit flag, and make sure `dispose()` stops/destroys the global bot instance. ``` // 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: async () => { const sessions = await store.listSessions(); const running = sessions.find(s => s.status === 'running'); return running?.id ?? null; }, }); twitchBot.start().catch(err => { logger.warn( `[Twitch Bot] Failed to start: ${err instanceof Error ? err.message : String(err)}`, ); }); registerStaticAndSpaRoutes(app, { publicDir, dashboardDir, }); const restartPendingIds = await store.recoverInterruptedSessions(); if (autoRunCourtSession) { for (const sessionId of restartPendingIds) { try { await recorder.start({ sessionId }); } catch (error) { logger.warn( `[replay] failed to start recorder for recovered session=${sessionId}: ${error instanceof Error ? error.message : String(error)}`, ); } void runCourtSession(sessionId, store); } } return { app, store, dispose: () => { clearInterval(pruneTimer); void recorder.dispose(); }, }; ``` </details> --- 💡 <a href="/subculture-collective/court/new/main?filename=.github/instructions/*.instructions.md" class="Link--inTextBlock" target="_blank" rel="noopener noreferrer">Add Copilot custom instructions</a> for smarter, more guided reviews. <a href="https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot" class="Link--inTextBlock" target="_blank" rel="noopener noreferrer">Learn how to get started</a>.
@@ -1007,6 +1008,34 @@ export async function createServerApp(
replay,
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2026-03-01 10:31:55 -06:00

getActiveSessionId calls store.listSessions() on every chat message. In the Postgres-backed store this loads all sessions and then fetches turns for each session, which is expensive and can become a bottleneck under active chat. Consider adding a cheaper way to resolve the running session ID (e.g., a targeted query/filter in the store, caching the running session ID on phase/session_started events, or an in-memory pointer updated by the orchestrator).

`getActiveSessionId` calls `store.listSessions()` on every chat message. In the Postgres-backed store this loads all sessions and then fetches turns for each session, which is expensive and can become a bottleneck under active chat. Consider adding a cheaper way to resolve the running session ID (e.g., a targeted query/filter in the store, caching the running session ID on phase/session_started events, or an in-memory pointer updated by the orchestrator).
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2026-03-01 10:31:55 -06:00

createServerApp calls twitchBot.start(), but TwitchBot.start() currently returns immediately because it checks if (!this.config || !this.isActive) return; and isActive starts as false. As a result the bot never connects even when credentials are present. Fix the start guard (should start when not already active) and ensure isActive is set appropriately during startup.

`createServerApp` calls `twitchBot.start()`, but `TwitchBot.start()` currently returns immediately because it checks `if (!this.config || !this.isActive) return;` and `isActive` starts as `false`. As a result the bot never connects even when credentials are present. Fix the start guard (should start when not already active) and ensure `isActive` is set appropriately during startup.
@@ -0,0 +88,4 @@
else process.env.TWITCH_CLIENT_SECRET = origClientSecret;
globalThis.fetch = originalFetch;
});
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2026-03-01 10:31:56 -06:00

This test overwrites process.env.TWITCH_* variables and then deletes them in after(). If the developer (or another test file) had these env vars set beforehand, they will be lost for the remainder of the test process. Save the previous values in before() and restore them in after() instead of unconditional deletion.

This test overwrites `process.env.TWITCH_*` variables and then deletes them in `after()`. If the developer (or another test file) had these env vars set beforehand, they will be lost for the remainder of the test process. Save the previous values in `before()` and restore them in `after()` instead of unconditional deletion.
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2026-03-01 10:31:55 -06:00

tmi.js identity username is set to this.config.channel. Twitch IRC authentication typically requires the bot account's username (which may differ from the channel being joined). Consider adding a separate botUsername/identityUsername config field (env var) and use that here to avoid login failures when the bot is a different account.

        const identityUsername =
            process.env.TWITCH_BOT_USERNAME || this.config.channel;

        this.tmiClient = new TmiClient({
            identity: {
                username: identityUsername,
`tmi.js` identity `username` is set to `this.config.channel`. Twitch IRC authentication typically requires the bot account's username (which may differ from the channel being joined). Consider adding a separate `botUsername`/`identityUsername` config field (env var) and use that here to avoid login failures when the bot is a different account. ```suggestion const identityUsername = process.env.TWITCH_BOT_USERNAME || this.config.channel; this.tmiClient = new TmiClient({ identity: { username: identityUsername, ```
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2026-03-01 10:31:55 -06:00

The IRC message handler ignores the self argument from tmi.js and will process the bot's own messages if it ever sends chat output, which can create feedback loops. Also, the handler is async without a surrounding try/catch; if getActiveSessionId() throws (or any awaited call rejects outside forwardCommand's fetch try/catch), this can become an unhandled rejection. Include the self parameter and return early when self === true, and wrap the handler body in a try/catch with logging.

            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,
                    );
                }
The IRC `message` handler ignores the `self` argument from tmi.js and will process the bot's own messages if it ever sends chat output, which can create feedback loops. Also, the handler is `async` without a surrounding try/catch; if `getActiveSessionId()` throws (or any awaited call rejects outside `forwardCommand`'s fetch try/catch), this can become an unhandled rejection. Include the `self` parameter and return early when `self === true`, and wrap the handler body in a try/catch with logging. ```suggestion 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, ); } ```
PatrickFanella commented 2026-03-01 10:39:41 -06:00 (Migrated from github.com)

@copilot open a new pull request to apply changes based on the comments in this thread

@copilot open a new pull request to apply changes based on the comments in [this thread](https://github.com/subculture-collective/court/pull/87#pullrequestreview-3872677556)
Copilot commented 2026-03-01 10:39:47 -06:00 (Migrated from github.com)

@PatrickFanella I've opened a new pull request, #89, to work on those changes. Once the pull request is ready, I'll request review from you.

@PatrickFanella I've opened a new pull request, #89, to work on those changes. Once the pull request is ready, I'll request review from you.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: subculture-collective/jury-rigged#87