revert(bridge): discord.js 봇 전면 제거 — OpenClaw 가 봇 소유자
이전 커밋 08ea92f 은 잘못된 접근이었음. OpenClaw 가 이미 Discord 게이트웨이를
띄우고 있고 자매들의 봇 identity 는 거기 하나로 통일되어야 함. rails 가
discord.js 로 별도 봇을 등록하면 두 봇이 같은 채널에 공존하는 기이한 구조가
된다.
올바른 경로는 OpenClaw skill 의 user-invocable frontmatter 로 슬래시 커맨드를
노출하는 것이고, 이건 별도 커밋으로 ~/.openclaw/skills/hanarang-rails/ 에
반영됨.
삭제:
- src/bridge/ 전체 (discord-client, discord-commands, discord-notifier, index)
- tests/discord-notifier.test.ts
- package.json 의 discord.js 의존성
- src/cli/serve.ts 의 bridge 부트스트랩
- .env.example 의 DISCORD_* 블록
보존:
- runner.ts 의 PipelineLifecycleEvent + onEvent 훅 — 유닛 테스트/대시보드
WebSocket 등에 재사용 가능
- POST /pipelines/start-async 엔드포인트 — skill 의 polling wrapper 가 이걸 씀
- runner opts.pipelineId 지원 — async start 가 의존함
- http.ts ServerOpts 의 onPipelineEvent/notifier 파라미터 — 추상화는 유지,
serve.ts 가 주입을 안 할 뿐
테스트: 118 → 111 (discord-notifier 7 개 삭제), 나머지 그대로 통과.
This commit is contained in:
31
.env.example
31
.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:<name> requirements:<text>` 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 <id> and /rails abort <id> 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.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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",
|
||||
|
||||
198
pnpm-lock.yaml
generated
198
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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<void> {
|
||||
if (this.ready) return;
|
||||
await new Promise<void>((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<void> {
|
||||
if (!this.ready) return;
|
||||
await this.client.destroy();
|
||||
this.ready = false;
|
||||
}
|
||||
|
||||
private async fetchChannel(id: string): Promise<TextChannel> {
|
||||
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<string> {
|
||||
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<ThreadChannel> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<SlashCommandBuilder["toJSON"]>
|
||||
> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
plan: "📋",
|
||||
implement: "🔨",
|
||||
review: "🔍",
|
||||
deploy: "🚀",
|
||||
};
|
||||
|
||||
const STAGE_KOREAN: Record<string, string> = {
|
||||
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<string, string>();
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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) + "…";
|
||||
}
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<BridgeHandle | null> {
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<void> {
|
||||
// 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<typeof makeFakeDiscord>;
|
||||
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<string>;
|
||||
}).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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user