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:
parent
7732558d04
commit
31af5ecfbd
6 changed files with 673 additions and 0 deletions
271
package-lock.json
generated
271
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@gsd-build/rpc-client": "^2.52.0",
|
||||
"discord.js": "^14.25.1",
|
||||
"yaml": "^2.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
224
packages/daemon/src/discord-bot.test.ts
Normal file
224
packages/daemon/src/discord-bot.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
148
packages/daemon/src/discord-bot.ts
Normal file
148
packages/daemon/src/discord-bot.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue