feat: Added discord.js v14, DiscordBot class with auth guard and lifecy…

- "packages/daemon/src/discord-bot.ts"
- "packages/daemon/src/discord-bot.test.ts"
- "packages/daemon/src/daemon.ts"
- "packages/daemon/src/index.ts"
- "packages/daemon/package.json"

GSD-Task: S03/T01
This commit is contained in:
Lex Christopherson 2026-03-27 14:33:36 -06:00
parent 7732558d04
commit 31af5ecfbd
6 changed files with 673 additions and 0 deletions

271
package-lock.json generated
View file

@ -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": {

View file

@ -29,6 +29,7 @@
},
"dependencies": {
"@gsd-build/rpc-client": "^2.52.0",
"discord.js": "^14.25.1",
"yaml": "^2.8.0"
},
"devDependencies": {

View file

@ -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();

View file

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

View file

@ -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<DaemonConfig['discord']> {
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<DaemonConfig['discord']>;
logger: Logger;
sessionManager: SessionManager;
}
export class DiscordBot {
private client: Client | null = null;
private destroyed = false;
private readonly config: NonNullable<DaemonConfig['discord']>;
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<void> {
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<void> {
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,
});
}
}

View file

@ -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';