diff --git a/.env.example b/.env.example index 49e800a..bc1e95b 100644 --- a/.env.example +++ b/.env.example @@ -116,24 +116,15 @@ RAILS_API_URL=http://127.0.0.1:18800 # GIT_PUSH_ENABLED=true # force on/off; default is auto (on iff GITEA_TOKEN set) -# ==== 5. OPTIONAL — Discord bridge ==================================== -# -# When all three DISCORD_* vars are set, `rails serve` starts a Discord -# bot that: -# -# - Registers a /rails slash command tree in the target guild -# - Accepts `/rails start project: requirements:` to kick -# off pipelines from Discord -# - Opens a public thread under DISCORD_NOTIFY_CHANNEL_ID for each -# pipeline it starts, and posts real-time updates there -# - Posts escalations (retry exhausted, fatal error) to the notify -# channel as top-level messages -# - /rails status and /rails abort are also available -# -# Leave any of the three empty and the bridge is a no-op. Pipelines still -# run; they just don't ping Discord. - -# DISCORD_TOKEN= -# DISCORD_GUILD_ID= -# DISCORD_NOTIFY_CHANNEL_ID= +# ==== 5. OPTIONAL — Gitea webhook receiver ============================ # GITEA_WEBHOOK_SECRET= + +# ────────────────────────────────────────────────────────────────────── +# Discord integration is NOT handled inside rails. The hanarang 4-sister +# deployment uses OpenClaw's built-in Discord gateway, and the slash +# command (`/hanarang_rails ...`) is exposed via an OpenClaw skill whose +# SKILL.md frontmatter has `user-invocable: true`. The skill's handler +# script POSTs to rails HTTP API just like any other caller. +# +# See ~/.openclaw/skills/hanarang-rails/SKILL.md for the wiring. +# ────────────────────────────────────────────────────────────────────── diff --git a/package.json b/package.json index b90c871..1929daa 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,6 @@ "dependencies": { "@prisma/client": "^6.6.0", "citty": "^0.1.6", - "discord.js": "^14.26.2", "neverthrow": "^8.2.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83884fe..3525c2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: citty: specifier: ^0.1.6 version: 0.1.6 - discord.js: - specifier: ^14.26.2 - version: 14.26.2 neverthrow: specifier: ^8.2.0 version: 8.2.0 @@ -54,34 +51,6 @@ importers: packages: - '@discordjs/builders@1.14.1': - resolution: {integrity: sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==} - engines: {node: '>=16.11.0'} - - '@discordjs/collection@1.5.3': - resolution: {integrity: sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==} - engines: {node: '>=16.11.0'} - - '@discordjs/collection@2.1.1': - resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==} - engines: {node: '>=18'} - - '@discordjs/formatters@0.6.2': - resolution: {integrity: sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==} - engines: {node: '>=16.11.0'} - - '@discordjs/rest@2.6.1': - resolution: {integrity: sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==} - engines: {node: '>=18'} - - '@discordjs/util@1.2.0': - resolution: {integrity: sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==} - engines: {node: '>=18'} - - '@discordjs/ws@1.2.3': - resolution: {integrity: sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==} - engines: {node: '>=16.11.0'} - '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -399,22 +368,6 @@ packages: cpu: [x64] os: [win32] - '@sapphire/async-queue@1.5.5': - resolution: {integrity: sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==} - engines: {node: '>=v14.0.0', npm: '>=7.0.0'} - - '@sapphire/shapeshift@4.0.0': - resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==} - engines: {node: '>=v16'} - - '@sapphire/snowflake@3.5.3': - resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==} - engines: {node: '>=v14.0.0', npm: '>=7.0.0'} - - '@sapphire/snowflake@3.5.5': - resolution: {integrity: sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==} - engines: {node: '>=v14.0.0', npm: '>=7.0.0'} - '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -430,9 +383,6 @@ packages: '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} - '@types/ws@8.18.1': - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -462,10 +412,6 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@vladfrangu/async_event_emitter@2.4.7': - resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==} - engines: {node: '>=v14.0.0', npm: '>=7.0.0'} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -540,13 +486,6 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} - discord-api-types@0.38.45: - resolution: {integrity: sha512-DiI01i00FPv6n+hXcFkFxK8Y/rFRpKs6U6aP32N4T73nTbj37Eua3H/95TBpLktLWB6xnLXhYDGvyLq6zzYY2w==} - - discord.js@14.26.2: - resolution: {integrity: sha512-feShi+gULJ6R2MAA4/KkCFnkJcuVrROJrKk4czplzq8gE1oqhqgOy9K0Scu44B8oGeWKe04egquzf+ia6VtXAw==} - engines: {node: '>=18'} - dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -586,9 +525,6 @@ packages: fast-copy@4.0.2: resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} @@ -624,18 +560,9 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - lodash.snakecase@4.1.1: - resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} - - lodash@4.18.1: - resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - magic-bytes.js@1.13.0: - resolution: {integrity: sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -814,12 +741,6 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} - ts-mixer@6.0.4: - resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -832,10 +753,6 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@6.24.1: - resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==} - engines: {node: '>=18.17'} - vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -917,18 +834,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - xstate@5.30.0: resolution: {integrity: sha512-mIzIuMjtYVkqXq9dUzYQoag7b/dF1CBS/yhliuPLfR0FwKPC18HiUivb/crcqY2gknhR8gJEhnppLg6ubQ0gGw==} @@ -942,55 +847,6 @@ packages: snapshots: - '@discordjs/builders@1.14.1': - dependencies: - '@discordjs/formatters': 0.6.2 - '@discordjs/util': 1.2.0 - '@sapphire/shapeshift': 4.0.0 - discord-api-types: 0.38.45 - fast-deep-equal: 3.1.3 - ts-mixer: 6.0.4 - tslib: 2.8.1 - - '@discordjs/collection@1.5.3': {} - - '@discordjs/collection@2.1.1': {} - - '@discordjs/formatters@0.6.2': - dependencies: - discord-api-types: 0.38.45 - - '@discordjs/rest@2.6.1': - dependencies: - '@discordjs/collection': 2.1.1 - '@discordjs/util': 1.2.0 - '@sapphire/async-queue': 1.5.5 - '@sapphire/snowflake': 3.5.5 - '@vladfrangu/async_event_emitter': 2.4.7 - discord-api-types: 0.38.45 - magic-bytes.js: 1.13.0 - tslib: 2.8.1 - undici: 6.24.1 - - '@discordjs/util@1.2.0': - dependencies: - discord-api-types: 0.38.45 - - '@discordjs/ws@1.2.3': - dependencies: - '@discordjs/collection': 2.1.1 - '@discordjs/rest': 2.6.1 - '@discordjs/util': 1.2.0 - '@sapphire/async-queue': 1.5.5 - '@types/ws': 8.18.1 - '@vladfrangu/async_event_emitter': 2.4.7 - discord-api-types: 0.38.45 - tslib: 2.8.1 - ws: 8.20.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - '@esbuild/aix-ppc64@0.27.7': optional: true @@ -1183,17 +1039,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true - '@sapphire/async-queue@1.5.5': {} - - '@sapphire/shapeshift@4.0.0': - dependencies: - fast-deep-equal: 3.1.3 - lodash: 4.18.1 - - '@sapphire/snowflake@3.5.3': {} - - '@sapphire/snowflake@3.5.5': {} - '@standard-schema/spec@1.1.0': {} '@types/chai@5.2.3': @@ -1209,10 +1054,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/ws@8.18.1': - dependencies: - '@types/node': 22.19.17 - '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -1255,8 +1096,6 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@vladfrangu/async_event_emitter@2.4.7': {} - assertion-error@2.0.1: {} atomic-sleep@1.0.0: {} @@ -1318,27 +1157,6 @@ snapshots: destr@2.0.5: {} - discord-api-types@0.38.45: {} - - discord.js@14.26.2: - dependencies: - '@discordjs/builders': 1.14.1 - '@discordjs/collection': 1.5.3 - '@discordjs/formatters': 0.6.2 - '@discordjs/rest': 2.6.1 - '@discordjs/util': 1.2.0 - '@discordjs/ws': 1.2.3 - '@sapphire/snowflake': 3.5.3 - discord-api-types: 0.38.45 - fast-deep-equal: 3.1.3 - lodash.snakecase: 4.1.1 - magic-bytes.js: 1.13.0 - tslib: 2.8.1 - undici: 6.24.1 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - dotenv@16.6.1: {} effect@3.21.0: @@ -1397,8 +1215,6 @@ snapshots: fast-copy@4.0.2: {} - fast-deep-equal@3.1.3: {} - fast-safe-stringify@2.1.1: {} fdir@6.5.0(picomatch@4.0.4): @@ -1425,14 +1241,8 @@ snapshots: js-tokens@9.0.1: {} - lodash.snakecase@4.1.1: {} - - lodash@4.18.1: {} - loupe@3.2.1: {} - magic-bytes.js@1.13.0: {} - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1630,18 +1440,12 @@ snapshots: tinyspy@4.0.4: {} - ts-mixer@6.0.4: {} - - tslib@2.8.1: {} - typescript@5.9.3: {} ulid@2.4.0: {} undici-types@6.21.0: {} - undici@6.24.1: {} - vite-node@3.2.4(@types/node@22.19.17)(jiti@2.6.1)(yaml@2.8.3): dependencies: cac: 6.7.14 @@ -1725,8 +1529,6 @@ snapshots: wrappy@1.0.2: {} - ws@8.20.0: {} - xstate@5.30.0: {} yaml@2.8.3: {} diff --git a/src/bridge/discord-client.ts b/src/bridge/discord-client.ts deleted file mode 100644 index f67ea7a..0000000 --- a/src/bridge/discord-client.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { - Client, - GatewayIntentBits, - type TextChannel, - type ThreadChannel, - ChannelType, -} from "discord.js"; -import { childLogger } from "../logger.js"; - -const log = childLogger({ module: "discord-client" }); - -export interface DiscordBridgeConfig { - token: string; - guildId: string; - notifyChannelId: string; -} - -/** - * Load Discord bridge config from env. Returns null if DISCORD_TOKEN is - * missing — the bridge is opt-in, so a missing token is expected for local - * dev without Discord. - */ -export function loadDiscordConfig(): DiscordBridgeConfig | null { - const token = process.env["DISCORD_TOKEN"]; - const guildId = process.env["DISCORD_GUILD_ID"]; - const notifyChannelId = process.env["DISCORD_NOTIFY_CHANNEL_ID"]; - if (!token || !guildId || !notifyChannelId) { - log.info( - { - tokenSet: Boolean(token), - guildIdSet: Boolean(guildId), - channelIdSet: Boolean(notifyChannelId), - }, - "Discord bridge not configured — skipping", - ); - return null; - } - return { token, guildId, notifyChannelId }; -} - -/** - * Thin wrapper around discord.js Client with the helpers the bridge needs: - * - * - login / ready / destroy lifecycle - * - postToChannel / postToThread - * - createPipelineThread : make a public thread named after a pipeline - * - * Keep all raw discord.js access contained here so the notifier and - * command handler don't have to know about GatewayIntents. - */ -export class DiscordClientWrapper { - readonly client: Client; - private readonly cfg: DiscordBridgeConfig; - private ready = false; - - constructor(cfg: DiscordBridgeConfig) { - this.cfg = cfg; - this.client = new Client({ - intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages], - }); - } - - get config(): DiscordBridgeConfig { - return this.cfg; - } - - async start(): Promise { - if (this.ready) return; - await new Promise((resolveFn, rejectFn) => { - this.client.once("ready", () => { - this.ready = true; - log.info( - { user: this.client.user?.tag }, - "Discord bridge logged in", - ); - resolveFn(); - }); - this.client.once("error", rejectFn); - this.client.login(this.cfg.token).catch(rejectFn); - }); - } - - async stop(): Promise { - if (!this.ready) return; - await this.client.destroy(); - this.ready = false; - } - - private async fetchChannel(id: string): Promise { - const ch = await this.client.channels.fetch(id); - if (!ch || ch.type !== ChannelType.GuildText) { - throw new Error( - `Discord channel ${id} is missing or not a guild text channel`, - ); - } - return ch as TextChannel; - } - - async postToChannel( - channelId: string, - content: string, - ): Promise { - const ch = await this.fetchChannel(channelId); - const msg = await ch.send({ content }); - return msg.id; - } - - async createPipelineThread(opts: { - channelId: string; - name: string; - reason: string; - }): Promise { - const parent = await this.fetchChannel(opts.channelId); - const thread = await parent.threads.create({ - name: opts.name.slice(0, 100), - autoArchiveDuration: 60, // minutes - reason: opts.reason, - type: ChannelType.PublicThread, - }); - return thread; - } - - async postToThread(threadId: string, content: string): Promise { - const ch = await this.client.channels.fetch(threadId); - if (!ch || !ch.isThread()) { - throw new Error(`Discord channel ${threadId} is not a thread`); - } - const msg = await (ch as ThreadChannel).send({ content }); - return msg.id; - } - - isReady(): boolean { - return this.ready; - } -} diff --git a/src/bridge/discord-commands.ts b/src/bridge/discord-commands.ts deleted file mode 100644 index 8cd3035..0000000 --- a/src/bridge/discord-commands.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { - REST, - Routes, - SlashCommandBuilder, - type ChatInputCommandInteraction, - type Interaction, -} from "discord.js"; -import type { DiscordClientWrapper } from "./discord-client.js"; -import type { DiscordNotifier } from "./discord-notifier.js"; -import { childLogger } from "../logger.js"; - -const log = childLogger({ module: "discord-commands" }); - -export interface CommandHandlerOptions { - discord: DiscordClientWrapper; - notifier: DiscordNotifier; - /** Loopback rails HTTP URL, e.g. http://127.0.0.1:18800 */ - railsApiUrl: string; -} - -/** - * Build the `/rails` command tree. - */ -function buildCommandDefinitions(): Array< - ReturnType -> { - const rails = new SlashCommandBuilder() - .setName("rails") - .setDescription("hanarang-rails pipeline controls") - .addSubcommand((sub) => - sub - .setName("start") - .setDescription("Start a new pipeline") - .addStringOption((opt) => - opt - .setName("project") - .setDescription("Project name") - .setRequired(true), - ) - .addStringOption((opt) => - opt - .setName("requirements") - .setDescription("What should the pipeline do?") - .setRequired(true), - ), - ) - .addSubcommand((sub) => - sub - .setName("status") - .setDescription("Inspect a pipeline's current state") - .addStringOption((opt) => - opt - .setName("id") - .setDescription("Pipeline ULID") - .setRequired(true), - ), - ) - .addSubcommand((sub) => - sub - .setName("abort") - .setDescription("Abort a running pipeline") - .addStringOption((opt) => - opt - .setName("id") - .setDescription("Pipeline ULID") - .setRequired(true), - ), - ); - return [rails.toJSON()]; -} - -/** - * Register the slash command tree with Discord via REST. This only needs to - * run once per deploy (Discord caches it) but running it at every bridge - * startup is idempotent. - */ -export async function registerSlashCommands( - discord: DiscordClientWrapper, -): Promise { - const cfg = discord.config; - const rest = new REST({ version: "10" }).setToken(cfg.token); - const clientId = discord.client.user?.id; - if (!clientId) { - throw new Error("Discord client not ready — cannot register commands"); - } - await rest.put(Routes.applicationGuildCommands(clientId, cfg.guildId), { - body: buildCommandDefinitions(), - }); - log.info({ guildId: cfg.guildId }, "Slash commands registered"); -} - -/** - * Wire the InteractionCreate listener to the rails HTTP API. - */ -export function installCommandHandler(opts: CommandHandlerOptions): void { - opts.discord.client.on("interactionCreate", (interaction) => { - void handleInteraction(interaction, opts).catch((err) => { - log.error( - { err: err instanceof Error ? err.message : String(err) }, - "interaction handler failed", - ); - }); - }); -} - -async function handleInteraction( - interaction: Interaction, - opts: CommandHandlerOptions, -): Promise { - if (!interaction.isChatInputCommand()) return; - if (interaction.commandName !== "rails") return; - - const sub = interaction.options.getSubcommand(true); - switch (sub) { - case "start": - return handleStart(interaction, opts); - case "status": - return handleStatus(interaction, opts); - case "abort": - return handleAbort(interaction, opts); - default: - await interaction.reply({ - content: `Unknown subcommand: ${sub}`, - ephemeral: true, - }); - } -} - -async function handleStart( - interaction: ChatInputCommandInteraction, - opts: CommandHandlerOptions, -): Promise { - const project = interaction.options.getString("project", true); - const requirements = interaction.options.getString("requirements", true); - - await interaction.deferReply(); - - // Kick off the pipeline on the rails loopback API - let pipelineId: string; - try { - const res = await fetch(`${opts.railsApiUrl}/pipelines/start-async`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ project, requirements }), - }); - if (!res.ok) { - const text = await res.text(); - await interaction.editReply( - `❌ Rails API rejected the request (${res.status}): ${text.slice(0, 300)}`, - ); - return; - } - const data = (await res.json()) as { pipelineId: string }; - pipelineId = data.pipelineId; - } catch (err) { - await interaction.editReply( - `❌ Rails API unreachable: ${err instanceof Error ? err.message : String(err)}`, - ); - return; - } - - // Create a thread under the notify channel for this pipeline - let threadId: string; - try { - const thread = await opts.discord.createPipelineThread({ - channelId: opts.discord.config.notifyChannelId, - name: `🚦 ${project} — ${pipelineId.slice(0, 8)}`, - reason: `rails pipeline ${pipelineId}`, - }); - threadId = thread.id; - opts.notifier.bindPipelineThread(pipelineId, threadId); - await thread.send( - [ - `🚦 **파이프라인 시작됨**`, - `ID: \`${pipelineId}\``, - `프로젝트: **${project}**`, - `요구사항: ${requirements.slice(0, 1000)}`, - "", - `실시간 업데이트가 이 스레드에 계속 올라옵니다.`, - ].join("\n"), - ); - } catch (err) { - log.warn( - { err: err instanceof Error ? err.message : String(err) }, - "thread creation failed — posting updates to channel instead", - ); - } - - await interaction.editReply( - `✅ 파이프라인 큐에 올렸어요: \`${pipelineId}\``, - ); -} - -async function handleStatus( - interaction: ChatInputCommandInteraction, - opts: CommandHandlerOptions, -): Promise { - const id = interaction.options.getString("id", true); - await interaction.deferReply({ ephemeral: true }); - try { - const res = await fetch(`${opts.railsApiUrl}/pipelines/${id}`); - if (!res.ok) { - await interaction.editReply(`❌ ${res.status}: pipeline not found`); - return; - } - const data = (await res.json()) as { state: string }; - await interaction.editReply( - `Pipeline \`${id.slice(0, 8)}\` → **${data.state}**`, - ); - } catch (err) { - await interaction.editReply( - `❌ ${err instanceof Error ? err.message : String(err)}`, - ); - } -} - -async function handleAbort( - interaction: ChatInputCommandInteraction, - opts: CommandHandlerOptions, -): Promise { - const id = interaction.options.getString("id", true); - await interaction.deferReply({ ephemeral: true }); - try { - const res = await fetch(`${opts.railsApiUrl}/pipelines/${id}/abort`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ reason: `aborted via discord by ${interaction.user.tag}` }), - }); - if (!res.ok) { - await interaction.editReply(`❌ ${res.status}: abort failed`); - return; - } - const data = (await res.json()) as { state: string }; - await interaction.editReply( - `Pipeline \`${id.slice(0, 8)}\` aborted → **${data.state}**`, - ); - } catch (err) { - await interaction.editReply( - `❌ ${err instanceof Error ? err.message : String(err)}`, - ); - } -} diff --git a/src/bridge/discord-notifier.ts b/src/bridge/discord-notifier.ts deleted file mode 100644 index d8b420d..0000000 --- a/src/bridge/discord-notifier.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { - PipelineLifecycleEvent, - PipelineEventListener, -} from "../orchestrator/runner.js"; -import type { EscalationNotifier } from "../resilience/escalate.js"; -import type { DiscordClientWrapper } from "./discord-client.js"; -import { childLogger } from "../logger.js"; - -const log = childLogger({ module: "discord-notifier" }); - -const STAGE_EMOJI: Record = { - plan: "📋", - implement: "🔨", - review: "🔍", - deploy: "🚀", -}; - -const STAGE_KOREAN: Record = { - plan: "기획", - implement: "구현", - review: "검토", - deploy: "배포", -}; - -/** - * DiscordNotifier — posts pipeline lifecycle events to Discord. - * - * Two delivery modes: - * - * 1. **Thread mode**: when a pipeline was started by a Discord slash - * command, the command handler opens a thread and registers the - * mapping here. Subsequent events go to that thread. - * - * 2. **Channel mode**: when a pipeline was started via CLI / HTTP, there - * is no thread, so events are posted to the top-level notify channel. - * - * The notifier also implements EscalationNotifier so it can be injected - * into the resilience layer without knowing it's Discord. - */ -export class DiscordNotifier implements EscalationNotifier { - private readonly pipelineThreads = new Map(); - - constructor(private readonly discord: DiscordClientWrapper) {} - - /** Registered by the slash command handler after creating a thread. */ - bindPipelineThread(pipelineId: string, threadId: string): void { - this.pipelineThreads.set(pipelineId, threadId); - } - - unbindPipelineThread(pipelineId: string): void { - this.pipelineThreads.delete(pipelineId); - } - - /** Returns a callback compatible with runner's onEvent hook. */ - asListener(): PipelineEventListener { - return (evt) => { - void this.handle(evt).catch((err) => { - log.warn( - { err: err instanceof Error ? err.message : String(err) }, - "Discord notifier handler failed", - ); - }); - }; - } - - /** EscalationNotifier interface — called from resilience/escalate.ts. */ - async notify(message: { - title: string; - body: string; - mentionUser?: boolean; - }): Promise { - const content = `**${message.title}**\n\n${message.body}`; - try { - await this.discord.postToChannel( - this.discord.config.notifyChannelId, - content, - ); - } catch (err) { - log.warn( - { err: err instanceof Error ? err.message : String(err) }, - "Discord escalation post failed", - ); - } - } - - private async handle(evt: PipelineLifecycleEvent): Promise { - const content = this.render(evt); - if (!content) return; - const threadId = this.pipelineThreads.get(evt.pipelineId); - try { - if (threadId) { - await this.discord.postToThread(threadId, content); - } else { - await this.discord.postToChannel( - this.discord.config.notifyChannelId, - content, - ); - } - } catch (err) { - log.warn( - { - pipelineId: evt.pipelineId, - evt: evt.type, - err: err instanceof Error ? err.message : String(err), - }, - "Discord post failed", - ); - } - // Auto-unbind on terminal events so long-lived bridges don't leak. - if ( - evt.type === "completed" || - evt.type === "failed" || - evt.type === "escalated" - ) { - // Keep the mapping for escalated — user may resume and emit more events - if (evt.type !== "escalated") { - this.unbindPipelineThread(evt.pipelineId); - } - } - } - - private render(evt: PipelineLifecycleEvent): string | null { - const short = evt.pipelineId.slice(0, 8); - switch (evt.type) { - case "started": - return [ - `🚦 **파이프라인 시작** \`${short}\``, - `📦 프로젝트: **${evt.projectName}**`, - `📝 요구사항: ${truncate(evt.requirements, 500)}`, - ].join("\n"); - - case "stage-done": { - const emoji = STAGE_EMOJI[evt.stage] ?? "✅"; - const ko = STAGE_KOREAN[evt.stage] ?? evt.stage; - const summary = evt.text ? `\n${truncate(evt.text, 800)}` : ""; - return `${emoji} **${ko} 완료** \`${short}\`${summary}`; - } - - case "stage-failed": { - const ko = STAGE_KOREAN[evt.stage] ?? evt.stage; - return `⚠️ **${ko} 실패** \`${short}\`\n${truncate(evt.reason, 600)}`; - } - - case "completed": - return [ - `✅ **파이프라인 완료** \`${short}\``, - `전이: ${evt.transitions} · 최종 상태: \`${evt.finalState}\``, - ].join("\n"); - - case "failed": - return `❌ **파이프라인 실패** \`${short}\`\n${truncate(evt.reason, 600)}`; - - case "escalated": - return [ - `🚨 **에스컬레이션 — 사용자 개입 필요** \`${short}\``, - `단계: \`${evt.stage}\` · 시도: ${evt.attempts}`, - `사유: ${truncate(evt.reason, 600)}`, - "", - "복구 명령:", - ` \`rails resume ${evt.pipelineId}\``, - ` \`rails abort ${evt.pipelineId}\``, - ].join("\n"); - - default: { - const exhaustive: never = evt; - void exhaustive; - return null; - } - } - } -} - -function truncate(text: string, max: number): string { - if (!text) return ""; - if (text.length <= max) return text; - return text.slice(0, max - 1) + "…"; -} diff --git a/src/bridge/index.ts b/src/bridge/index.ts deleted file mode 100644 index 8ae2530..0000000 --- a/src/bridge/index.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - DiscordClientWrapper, - loadDiscordConfig, -} from "./discord-client.js"; -import { DiscordNotifier } from "./discord-notifier.js"; -import { - installCommandHandler, - registerSlashCommands, -} from "./discord-commands.js"; -import type { PipelineEventListener } from "../orchestrator/runner.js"; -import type { EscalationNotifier } from "../resilience/escalate.js"; -import { childLogger } from "../logger.js"; - -const log = childLogger({ module: "bridge" }); - -export interface BridgeHandle { - notifier: DiscordNotifier; - onPipelineEvent: PipelineEventListener; - escalationNotifier: EscalationNotifier; - stop: () => Promise; -} - -/** - * Start the Discord bridge if DISCORD_TOKEN / DISCORD_GUILD_ID / - * DISCORD_NOTIFY_CHANNEL_ID are set. Returns a handle with the listener - * that rails serve should inject into runPipeline. - * - * Returns null when Discord env is missing — callers should treat null as - * "bridge not configured, skip". - */ -export async function startDiscordBridge(opts: { - railsApiUrl: string; -}): Promise { - const cfg = loadDiscordConfig(); - if (!cfg) return null; - - const discord = new DiscordClientWrapper(cfg); - try { - await discord.start(); - } catch (err) { - log.error( - { err: err instanceof Error ? err.message : String(err) }, - "Discord login failed — bridge disabled", - ); - return null; - } - - const notifier = new DiscordNotifier(discord); - - try { - await registerSlashCommands(discord); - } catch (err) { - log.warn( - { err: err instanceof Error ? err.message : String(err) }, - "Slash command registration failed — commands may not appear", - ); - } - - installCommandHandler({ - discord, - notifier, - railsApiUrl: opts.railsApiUrl, - }); - - log.info( - { channelId: cfg.notifyChannelId, guildId: cfg.guildId }, - "Discord bridge ready", - ); - - return { - notifier, - onPipelineEvent: notifier.asListener(), - escalationNotifier: notifier, - async stop() { - await discord.stop(); - }, - }; -} diff --git a/src/cli/serve.ts b/src/cli/serve.ts index ae57f4a..347ae34 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -3,7 +3,6 @@ import { loadEnv } from "../env.js"; import { getLogger } from "../logger.js"; import { startHttpServer } from "../server/http.js"; import { disconnectPrisma } from "../orchestrator/persist.js"; -import { startDiscordBridge, type BridgeHandle } from "../bridge/index.js"; export default defineCommand({ meta: { @@ -36,30 +35,14 @@ export default defineCommand({ const port = parseInt(args.port || String(env.RAILS_PORT), 10); - // Bring up Discord bridge first (if configured) so the notifier can be - // injected into every runPipeline call the HTTP server makes. - let bridge: BridgeHandle | null = null; - try { - bridge = await startDiscordBridge({ - railsApiUrl: `http://127.0.0.1:${port}`, - }); - } catch (err) { - log.warn( - { err: err instanceof Error ? err.message : String(err) }, - "Discord bridge failed to start — continuing without it", - ); - } - const { url, close } = await startHttpServer({ port, host: args.host ?? "0.0.0.0", ...(args.config && { configPath: args.config }), - ...(bridge && { onPipelineEvent: bridge.onPipelineEvent }), - ...(bridge && { notifier: bridge.escalationNotifier }), }); log.info( - { url, nodeEnv: env.NODE_ENV, discordBridge: Boolean(bridge) }, + { url, nodeEnv: env.NODE_ENV }, "hanarang-rails server ready", ); @@ -67,7 +50,6 @@ export default defineCommand({ log.info({ signal }, "Shutdown requested"); try { await close(); - if (bridge) await bridge.stop(); await disconnectPrisma(); } catch (err) { log.error( diff --git a/tests/discord-notifier.test.ts b/tests/discord-notifier.test.ts deleted file mode 100644 index ee40080..0000000 --- a/tests/discord-notifier.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { DiscordNotifier } from "../src/bridge/discord-notifier.js"; -import type { DiscordClientWrapper } from "../src/bridge/discord-client.js"; -import type { PipelineLifecycleEvent } from "../src/orchestrator/runner.js"; - -/** - * The DiscordNotifier is the interesting unit — it contains the routing - * logic (channel vs thread) and the message rendering. We exercise it - * against a fake DiscordClientWrapper that records every call. - */ - -function makeFakeDiscord(): { - fake: DiscordClientWrapper; - channelPosts: Array<{ channelId: string; content: string }>; - threadPosts: Array<{ threadId: string; content: string }>; -} { - const channelPosts: Array<{ channelId: string; content: string }> = []; - const threadPosts: Array<{ threadId: string; content: string }> = []; - const fake = { - config: { - token: "x", - guildId: "g", - notifyChannelId: "channel-123", - }, - async postToChannel(channelId: string, content: string) { - channelPosts.push({ channelId, content }); - return "msg-id"; - }, - async postToThread(threadId: string, content: string) { - threadPosts.push({ threadId, content }); - return "msg-id"; - }, - } as unknown as DiscordClientWrapper; - return { fake, channelPosts, threadPosts }; -} - -async function flush(): Promise { - // The notifier dispatches via .catch on a floating promise — give - // microtasks a chance to run before we assert. - await new Promise((r) => setTimeout(r, 10)); -} - -describe("DiscordNotifier", () => { - let fake: ReturnType; - let notifier: DiscordNotifier; - - beforeEach(() => { - fake = makeFakeDiscord(); - notifier = new DiscordNotifier(fake.fake); - }); - - it("posts to channel when no thread is bound", async () => { - const listener = notifier.asListener(); - const evt: PipelineLifecycleEvent = { - type: "started", - pipelineId: "01HXYZTEST1234567890ABCDE", - projectName: "demo", - requirements: "make a toy app", - }; - listener(evt); - await flush(); - - expect(fake.channelPosts).toHaveLength(1); - expect(fake.threadPosts).toHaveLength(0); - expect(fake.channelPosts[0]!.channelId).toBe("channel-123"); - expect(fake.channelPosts[0]!.content).toContain("파이프라인 시작"); - expect(fake.channelPosts[0]!.content).toContain("demo"); - }); - - it("posts to thread when one is bound", async () => { - notifier.bindPipelineThread("01HXYZTEST1234567890ABCDE", "thread-777"); - const listener = notifier.asListener(); - listener({ - type: "stage-done", - pipelineId: "01HXYZTEST1234567890ABCDE", - stage: "plan", - text: "plan ok", - }); - await flush(); - - expect(fake.threadPosts).toHaveLength(1); - expect(fake.channelPosts).toHaveLength(0); - expect(fake.threadPosts[0]!.threadId).toBe("thread-777"); - expect(fake.threadPosts[0]!.content).toContain("기획 완료"); - }); - - it("renders every lifecycle event type", async () => { - notifier.bindPipelineThread("01HXYZTEST1234567890ABCDE", "thread-1"); - const listener = notifier.asListener(); - - const events: PipelineLifecycleEvent[] = [ - { - type: "started", - pipelineId: "01HXYZTEST1234567890ABCDE", - projectName: "p", - requirements: "r", - }, - { - type: "stage-done", - pipelineId: "01HXYZTEST1234567890ABCDE", - stage: "implement", - text: "impl ok", - }, - { - type: "stage-failed", - pipelineId: "01HXYZTEST1234567890ABCDE", - stage: "review", - reason: "something broke", - }, - { - type: "escalated", - pipelineId: "01HXYZTEST1234567890ABCDE", - stage: "implement", - reason: "3 retries failed", - attempts: 3, - }, - { - type: "completed", - pipelineId: "01HXYZTEST1234567890ABCDE", - finalState: "done", - transitions: 5, - }, - ]; - for (const e of events) listener(e); - await flush(); - - const contents = fake.threadPosts.map((p) => p.content); - expect(contents.some((c) => c.includes("파이프라인 시작"))).toBe(true); - expect(contents.some((c) => c.includes("구현 완료"))).toBe(true); - expect(contents.some((c) => c.includes("검토 실패"))).toBe(true); - expect(contents.some((c) => c.includes("에스컬레이션"))).toBe(true); - expect(contents.some((c) => c.includes("파이프라인 완료"))).toBe(true); - }); - - it("unbinds thread after terminal 'completed' event", async () => { - notifier.bindPipelineThread("01HXYZTEST1234567890ABCDE", "thread-2"); - const listener = notifier.asListener(); - listener({ - type: "completed", - pipelineId: "01HXYZTEST1234567890ABCDE", - finalState: "done", - transitions: 5, - }); - await flush(); - - // A subsequent event should fall back to channel (thread unbound) - listener({ - type: "started", - pipelineId: "01HXYZTEST1234567890ABCDE", - projectName: "x", - requirements: "y", - }); - await flush(); - - // First post to thread, second to channel - expect(fake.threadPosts).toHaveLength(1); - expect(fake.channelPosts).toHaveLength(1); - }); - - it("keeps thread binding after 'escalated' (user may resume)", async () => { - notifier.bindPipelineThread("01HXYZTEST1234567890ABCDE", "thread-3"); - const listener = notifier.asListener(); - listener({ - type: "escalated", - pipelineId: "01HXYZTEST1234567890ABCDE", - stage: "plan", - reason: "halt", - attempts: 2, - }); - await flush(); - - listener({ - type: "stage-done", - pipelineId: "01HXYZTEST1234567890ABCDE", - stage: "plan", - text: "resumed", - }); - await flush(); - - expect(fake.threadPosts).toHaveLength(2); - expect(fake.channelPosts).toHaveLength(0); - }); - - it("EscalationNotifier.notify posts to channel", async () => { - await notifier.notify({ - title: "🚨 pipeline halted", - body: "three retries exhausted", - mentionUser: true, - }); - expect(fake.channelPosts).toHaveLength(1); - expect(fake.channelPosts[0]!.content).toContain("pipeline halted"); - expect(fake.channelPosts[0]!.content).toContain("three retries exhausted"); - }); - - it("swallows listener exceptions from postToChannel (non-fatal)", async () => { - const warnSpy = vi.fn(); - // Inject a failing postToChannel - const failingFake = makeFakeDiscord(); - (failingFake.fake as unknown as { - postToChannel: () => Promise; - }).postToChannel = async () => { - throw new Error("boom"); - }; - const failingNotifier = new DiscordNotifier(failingFake.fake); - failingNotifier.asListener()({ - type: "started", - pipelineId: "01HXYZTEST1234567890ABCDE", - projectName: "p", - requirements: "r", - }); - await flush(); - // If we get here without an uncaught rejection, the test passes. - expect(warnSpy).toBeDefined(); - }); -});