diff --git a/package-lock.json b/package-lock.json index 15fdddf16..c68db802d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1243,6 +1243,155 @@ "sisteransi": "^1.0.5" } }, + "node_modules/@discordjs/builders": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.0.tgz", + "integrity": "sha512-7pVKxVWkeLUtrTo9nTYkjRcJk0Hlms6lYervXAD7E7+K5lil9ms2JrEB1TalMiHvQMh7h1HJZ4fCJa0/vHpl4w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@discordjs/rest/node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/@electron/get": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", @@ -3065,6 +3214,39 @@ ], "peer": true }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@silvia-odwyer/photon-node": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", @@ -4245,6 +4427,15 @@ "@types/node": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -4276,6 +4467,16 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -5004,6 +5205,51 @@ "node": ">=0.3.1" } }, + "node_modules/discord-api-types": { + "version": "0.38.42", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.42.tgz", + "integrity": "sha512-qs1kya7S84r5RR8m9kgttywGrmmoHaRifU1askAoi+wkoSefLpZP6aGXusjNw5b0jD3zOg3LTwUa3Tf2iHIceQ==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.25.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", + "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.13.0", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.0", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/discord.js/node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6675,6 +6921,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -6700,6 +6958,12 @@ "node": "20 || >=22" } }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -8176,6 +8440,12 @@ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", "license": "MIT" }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -9159,6 +9429,7 @@ "license": "MIT", "dependencies": { "@gsd-build/rpc-client": "^2.52.0", + "discord.js": "^14.25.1", "yaml": "^2.8.0" }, "bin": { diff --git a/packages/daemon/package.json b/packages/daemon/package.json index ae2365eed..548eed60e 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@gsd-build/rpc-client": "^2.52.0", + "discord.js": "^14.25.1", "yaml": "^2.8.0" }, "devDependencies": { diff --git a/packages/daemon/src/daemon.ts b/packages/daemon/src/daemon.ts index 6015db481..727b583b7 100644 --- a/packages/daemon/src/daemon.ts +++ b/packages/daemon/src/daemon.ts @@ -2,6 +2,7 @@ import type { DaemonConfig, ProjectInfo } from './types.js'; import type { Logger } from './logger.js'; import { SessionManager } from './session-manager.js'; import { scanForProjects } from './project-scanner.js'; +import { DiscordBot, validateDiscordConfig } from './discord-bot.js'; /** * Core daemon class — ties config + logger together with lifecycle management. @@ -13,6 +14,7 @@ export class Daemon { private readonly onSigterm: () => void; private readonly onSigint: () => void; private sessionManager: SessionManager | undefined; + private discordBot: DiscordBot | undefined; constructor( private readonly config: DaemonConfig, @@ -38,6 +40,25 @@ export class Daemon { // Keep the event loop alive. The write stream alone doesn't hold a ref // when there's no pending I/O, so we need an explicit timer. this.keepaliveTimer = setInterval(() => {}, 60_000); + + // Conditionally start Discord bot if config is present and valid + if (this.config.discord?.token) { + try { + validateDiscordConfig(this.config.discord); + this.discordBot = new DiscordBot({ + config: this.config.discord, + logger: this.logger, + sessionManager: this.sessionManager, + }); + await this.discordBot.login(); + } catch (err) { + // Log error but don't abort daemon startup — bot is optional + this.logger.error('discord bot login failed', { + error: err instanceof Error ? err.message : String(err), + }); + this.discordBot = undefined; + } + } } /** Scan configured project roots for project directories. */ @@ -70,6 +91,12 @@ export class Daemon { this.keepaliveTimer = undefined; } + // Destroy Discord bot before session cleanup + if (this.discordBot) { + await this.discordBot.destroy(); + this.discordBot = undefined; + } + // Clean up active sessions before closing logger if (this.sessionManager) { await this.sessionManager.cleanup(); diff --git a/packages/daemon/src/discord-bot.test.ts b/packages/daemon/src/discord-bot.test.ts new file mode 100644 index 000000000..3d9c6835e --- /dev/null +++ b/packages/daemon/src/discord-bot.test.ts @@ -0,0 +1,224 @@ +import { describe, it, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { isAuthorized, validateDiscordConfig } from './discord-bot.js'; +import { Daemon } from './daemon.js'; +import { Logger } from './logger.js'; +import type { DaemonConfig, LogEntry } from './types.js'; + +// ---------- helpers ---------- + +function tmpDir(): string { + return mkdtempSync(join(tmpdir(), `discord-test-${randomUUID().slice(0, 8)}-`)); +} + +const cleanupDirs: string[] = []; +afterEach(() => { + while (cleanupDirs.length) { + const d = cleanupDirs.pop()!; + if (existsSync(d)) rmSync(d, { recursive: true, force: true }); + } +}); + +// ---------- isAuthorized ---------- + +describe('isAuthorized', () => { + it('returns true when userId matches ownerId', () => { + assert.equal(isAuthorized('12345', '12345'), true); + }); + + it('returns false when userId does not match ownerId', () => { + assert.equal(isAuthorized('12345', '99999'), false); + }); + + it('returns false when ownerId is empty', () => { + assert.equal(isAuthorized('12345', ''), false); + }); + + it('returns false when userId is empty', () => { + assert.equal(isAuthorized('', '12345'), false); + }); + + it('returns false when both are empty', () => { + assert.equal(isAuthorized('', ''), false); + }); +}); + +// ---------- validateDiscordConfig ---------- + +describe('validateDiscordConfig', () => { + it('passes with all required fields', () => { + assert.doesNotThrow(() => { + validateDiscordConfig({ + token: 'test-token', + guild_id: 'g123', + owner_id: 'o456', + }); + }); + }); + + it('throws on undefined config', () => { + assert.throws( + () => validateDiscordConfig(undefined), + (err: Error) => { + assert.ok(err.message.includes('undefined')); + return true; + }, + ); + }); + + it('throws on missing token', () => { + assert.throws( + () => validateDiscordConfig({ token: '', guild_id: 'g1', owner_id: 'o1' }), + (err: Error) => { + assert.ok(err.message.includes('token')); + return true; + }, + ); + }); + + it('throws on whitespace-only token', () => { + assert.throws( + () => validateDiscordConfig({ token: ' ', guild_id: 'g1', owner_id: 'o1' }), + (err: Error) => { + assert.ok(err.message.includes('token')); + return true; + }, + ); + }); + + it('throws on missing guild_id', () => { + assert.throws( + () => validateDiscordConfig({ token: 'tok', guild_id: '', owner_id: 'o1' }), + (err: Error) => { + assert.ok(err.message.includes('guild_id')); + return true; + }, + ); + }); + + it('throws on missing owner_id', () => { + assert.throws( + () => validateDiscordConfig({ token: 'tok', guild_id: 'g1', owner_id: '' }), + (err: Error) => { + assert.ok(err.message.includes('owner_id')); + return true; + }, + ); + }); +}); + +// ---------- Daemon wiring ---------- + +describe('Daemon + DiscordBot wiring', () => { + it('does not create DiscordBot when discord config is absent', async () => { + const dir = tmpDir(); + cleanupDirs.push(dir); + const logPath = join(dir, 'no-discord.log'); + + const config: DaemonConfig = { + discord: undefined, + projects: { scan_roots: [] }, + log: { file: logPath, level: 'debug', max_size_mb: 50 }, + }; + + const logger = new Logger({ filePath: logPath, level: 'debug' }); + const daemon = new Daemon(config, logger); + + await daemon.start(); + + const origExit = process.exit; + // @ts-expect-error — overriding process.exit for test + process.exit = () => {}; + try { + await daemon.shutdown(); + } finally { + process.exit = origExit; + } + + const content = readFileSync(logPath, 'utf-8'); + // Should NOT have any bot-related log entries + assert.ok(!content.includes('bot ready')); + assert.ok(!content.includes('discord bot login failed')); + assert.ok(!content.includes('bot destroyed')); + }); + + it('logs error when discord config has token but login fails (no real gateway)', async () => { + const dir = tmpDir(); + cleanupDirs.push(dir); + const logPath = join(dir, 'bad-token.log'); + + const config: DaemonConfig = { + discord: { + token: 'invalid-token-that-will-fail-login', + guild_id: 'g1', + owner_id: 'o1', + }, + projects: { scan_roots: [] }, + log: { file: logPath, level: 'debug', max_size_mb: 50 }, + }; + + const logger = new Logger({ filePath: logPath, level: 'debug' }); + const daemon = new Daemon(config, logger); + + // start() should NOT throw — bot login failure is non-fatal + await daemon.start(); + + const origExit = process.exit; + // @ts-expect-error — overriding process.exit for test + process.exit = () => {}; + try { + await daemon.shutdown(); + } finally { + process.exit = origExit; + } + + // Small flush delay + await new Promise((r) => setTimeout(r, 50)); + + const content = readFileSync(logPath, 'utf-8'); + // Should have logged the login failure + assert.ok(content.includes('discord bot login failed'), 'should log bot login failure'); + // Token should never appear in logs + assert.ok(!content.includes('invalid-token-that-will-fail-login'), 'token must not appear in logs'); + }); + + it('does not attempt login when discord config has no token', async () => { + const dir = tmpDir(); + cleanupDirs.push(dir); + const logPath = join(dir, 'no-token.log'); + + // Config with discord block but empty token + const config: DaemonConfig = { + discord: { + token: '', + guild_id: 'g1', + owner_id: 'o1', + }, + projects: { scan_roots: [] }, + log: { file: logPath, level: 'debug', max_size_mb: 50 }, + }; + + const logger = new Logger({ filePath: logPath, level: 'debug' }); + const daemon = new Daemon(config, logger); + + await daemon.start(); + + const origExit = process.exit; + // @ts-expect-error — overriding process.exit for test + process.exit = () => {}; + try { + await daemon.shutdown(); + } finally { + process.exit = origExit; + } + + const content = readFileSync(logPath, 'utf-8'); + // Should not attempt login — no token + assert.ok(!content.includes('discord bot login failed')); + assert.ok(!content.includes('bot ready')); + }); +}); diff --git a/packages/daemon/src/discord-bot.ts b/packages/daemon/src/discord-bot.ts new file mode 100644 index 000000000..f7e6ca7c9 --- /dev/null +++ b/packages/daemon/src/discord-bot.ts @@ -0,0 +1,148 @@ +/** + * DiscordBot — wraps discord.js Client with login/destroy lifecycle, auth guard, + * and integration with the daemon's SessionManager. + * + * Auth model (D016): single Discord user ID allowlist. All non-owner interactions + * silently ignored; rejections logged at debug level (userId only, no PII). + */ + +import { + Client, + GatewayIntentBits, + type Interaction, +} from 'discord.js'; +import type { DaemonConfig } from './types.js'; +import type { Logger } from './logger.js'; +import type { SessionManager } from './session-manager.js'; + +// --------------------------------------------------------------------------- +// Pure helpers — exported for testability +// --------------------------------------------------------------------------- + +/** + * Auth guard: returns true iff userId matches the configured owner_id. + * Rejects empty or missing ownerId to fail closed. + */ +export function isAuthorized(userId: string, ownerId: string): boolean { + if (!ownerId || !userId) return false; + return userId === ownerId; +} + +/** + * Validates that all required discord config fields are present. + * Throws with a descriptive message on the first missing field. + */ +export function validateDiscordConfig( + config: DaemonConfig['discord'], +): asserts config is NonNullable { + if (!config) { + throw new Error('Discord config is undefined'); + } + if (!config.token || config.token.trim() === '') { + throw new Error('Discord config missing required field: token'); + } + if (!config.guild_id || config.guild_id.trim() === '') { + throw new Error('Discord config missing required field: guild_id'); + } + if (!config.owner_id || config.owner_id.trim() === '') { + throw new Error('Discord config missing required field: owner_id'); + } +} + +// --------------------------------------------------------------------------- +// DiscordBot class +// --------------------------------------------------------------------------- + +export interface DiscordBotOptions { + config: NonNullable; + logger: Logger; + sessionManager: SessionManager; +} + +export class DiscordBot { + private client: Client | null = null; + private destroyed = false; + + private readonly config: NonNullable; + private readonly logger: Logger; + private readonly sessionManager: SessionManager; + + constructor(opts: DiscordBotOptions) { + this.config = opts.config; + this.logger = opts.logger; + this.sessionManager = opts.sessionManager; + } + + /** + * Create the discord.js Client, register event handlers, and log in. + * Throws on login failure — the caller (Daemon) decides whether to continue without the bot. + */ + async login(): Promise { + const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + ], + }); + + client.once('ready', (readyClient) => { + const guildNames = readyClient.guilds.cache.map((g) => g.name).join(', '); + this.logger.info('bot ready', { + username: readyClient.user.tag, + guilds: guildNames, + }); + }); + + client.on('interactionCreate', (interaction: Interaction) => { + this.handleInteraction(interaction); + }); + + await client.login(this.config.token); + this.client = client; + this.destroyed = false; + } + + /** + * Destroy the discord.js Client. Idempotent — safe to call multiple times + * or before login(). + */ + async destroy(): Promise { + if (this.destroyed || !this.client) { + this.destroyed = true; + return; + } + + try { + // discord.js destroy() is synchronous but may throw on double-destroy + this.client.destroy(); + this.logger.info('bot destroyed'); + } catch (err) { + // Swallow cleanup errors — shutdown must not fail + this.logger.debug('bot destroy error (swallowed)', { + error: err instanceof Error ? err.message : String(err), + }); + } finally { + this.client = null; + this.destroyed = true; + } + } + + // --------------------------------------------------------------------------- + // Private: interaction handling + // --------------------------------------------------------------------------- + + private handleInteraction(interaction: Interaction): void { + if (!isAuthorized(interaction.user.id, this.config.owner_id)) { + this.logger.debug('auth rejected', { userId: interaction.user.id }); + return; + } + + // Authorized — delegate to command handler (stub for T03 slash commands) + // For now, just log the interaction type for observability + this.logger.debug('interaction received', { + type: interaction.type, + userId: interaction.user.id, + }); + } +} diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index 172cff04d..11402c555 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -17,3 +17,5 @@ export type { LoggerOptions } from './logger.js'; export { Daemon } from './daemon.js'; export { scanForProjects } from './project-scanner.js'; export { SessionManager } from './session-manager.js'; +export { DiscordBot, isAuthorized, validateDiscordConfig } from './discord-bot.js'; +export type { DiscordBotOptions } from './discord-bot.js';