aicodeman 1.47.0 → 1.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/dist/cli-notify.d.ts +34 -0
- package/dist/cli-notify.d.ts.map +1 -0
- package/dist/cli-notify.js +100 -0
- package/dist/cli-notify.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +16 -0
- package/dist/cli.js.map +1 -1
- package/dist/config/cli-registry/patterns.d.ts +1 -1
- package/dist/config/cli-registry/patterns.d.ts.map +1 -1
- package/dist/config/cli-registry/patterns.js +4 -0
- package/dist/config/cli-registry/patterns.js.map +1 -1
- package/dist/config/cli-registry/schema.d.ts +7 -0
- package/dist/config/cli-registry/schema.d.ts.map +1 -1
- package/dist/config/cli-registry/schema.js +67 -3
- package/dist/config/cli-registry/schema.js.map +1 -1
- package/dist/config/cli-registry/stock.d.ts.map +1 -1
- package/dist/config/cli-registry/stock.js +114 -31
- package/dist/config/cli-registry/stock.js.map +1 -1
- package/dist/config/cli-registry/types.d.ts +51 -21
- package/dist/config/cli-registry/types.d.ts.map +1 -1
- package/dist/config/foreign-tmux.d.ts +48 -0
- package/dist/config/foreign-tmux.d.ts.map +1 -0
- package/dist/config/foreign-tmux.js +48 -0
- package/dist/config/foreign-tmux.js.map +1 -0
- package/dist/config/path-probe.d.ts +6 -0
- package/dist/config/path-probe.d.ts.map +1 -1
- package/dist/config/path-probe.js +6 -0
- package/dist/config/path-probe.js.map +1 -1
- package/dist/config-backup.d.ts +1 -1
- package/dist/config-backup.d.ts.map +1 -1
- package/dist/config-backup.js +2 -0
- package/dist/config-backup.js.map +1 -1
- package/dist/custom-model-hosts.d.ts +32 -8
- package/dist/custom-model-hosts.d.ts.map +1 -1
- package/dist/custom-model-hosts.js +10 -0
- package/dist/custom-model-hosts.js.map +1 -1
- package/dist/custom-model-injection-apply.d.ts +21 -5
- package/dist/custom-model-injection-apply.d.ts.map +1 -1
- package/dist/custom-model-injection-apply.js +121 -27
- package/dist/custom-model-injection-apply.js.map +1 -1
- package/dist/custom-model-injection.d.ts +6 -0
- package/dist/custom-model-injection.d.ts.map +1 -1
- package/dist/custom-model-injection.js +92 -10
- package/dist/custom-model-injection.js.map +1 -1
- package/dist/discord-bridge.d.ts +338 -0
- package/dist/discord-bridge.d.ts.map +1 -0
- package/dist/discord-bridge.js +1256 -0
- package/dist/discord-bridge.js.map +1 -0
- package/dist/docker-hosts.d.ts +8 -1
- package/dist/docker-hosts.d.ts.map +1 -1
- package/dist/docker-hosts.js +5 -5
- package/dist/docker-hosts.js.map +1 -1
- package/dist/edit-history-local-source.d.ts +83 -0
- package/dist/edit-history-local-source.d.ts.map +1 -0
- package/dist/edit-history-local-source.js +198 -0
- package/dist/edit-history-local-source.js.map +1 -0
- package/dist/edit-history-parse.d.ts +114 -0
- package/dist/edit-history-parse.d.ts.map +1 -0
- package/dist/edit-history-parse.js +312 -0
- package/dist/edit-history-parse.js.map +1 -0
- package/dist/edit-history-store.d.ts +209 -0
- package/dist/edit-history-store.d.ts.map +1 -0
- package/dist/edit-history-store.js +599 -0
- package/dist/edit-history-store.js.map +1 -0
- package/dist/foreign-tmux-discovery.d.ts +140 -0
- package/dist/foreign-tmux-discovery.d.ts.map +1 -0
- package/dist/foreign-tmux-discovery.js +366 -0
- package/dist/foreign-tmux-discovery.js.map +1 -0
- package/dist/foreign-tmux.d.ts +381 -0
- package/dist/foreign-tmux.d.ts.map +1 -0
- package/dist/foreign-tmux.js +775 -0
- package/dist/foreign-tmux.js.map +1 -0
- package/dist/git-clone.d.ts +6 -1
- package/dist/git-clone.d.ts.map +1 -1
- package/dist/git-clone.js +79 -14
- package/dist/git-clone.js.map +1 -1
- package/dist/git-fetch-scheduler.d.ts +2 -0
- package/dist/git-fetch-scheduler.d.ts.map +1 -1
- package/dist/git-fetch-scheduler.js +1 -1
- package/dist/git-fetch-scheduler.js.map +1 -1
- package/dist/git-fetch.d.ts +1 -1
- package/dist/git-fetch.d.ts.map +1 -1
- package/dist/git-workspace-status.d.ts +2 -2
- package/dist/git-workspace-status.d.ts.map +1 -1
- package/dist/hooks-config.d.ts +2 -1
- package/dist/hooks-config.d.ts.map +1 -1
- package/dist/hooks-config.js +2 -1
- package/dist/hooks-config.js.map +1 -1
- package/dist/image-watcher.js +4 -4
- package/dist/image-watcher.js.map +1 -1
- package/dist/live-worktree.d.ts +53 -0
- package/dist/live-worktree.d.ts.map +1 -0
- package/dist/live-worktree.js +97 -0
- package/dist/live-worktree.js.map +1 -0
- package/dist/mux-interface.d.ts +22 -2
- package/dist/mux-interface.d.ts.map +1 -1
- package/dist/reboot-restore.d.ts +1 -1
- package/dist/reboot-restore.d.ts.map +1 -1
- package/dist/reboot-restore.js +9 -0
- package/dist/reboot-restore.js.map +1 -1
- package/dist/remote-files.d.ts +150 -5
- package/dist/remote-files.d.ts.map +1 -1
- package/dist/remote-files.js +505 -7
- package/dist/remote-files.js.map +1 -1
- package/dist/remote-hosts.d.ts +15 -9
- package/dist/remote-hosts.d.ts.map +1 -1
- package/dist/remote-hosts.js +42 -20
- package/dist/remote-hosts.js.map +1 -1
- package/dist/session-cli-registry-bridge.d.ts.map +1 -1
- package/dist/session-cli-registry-bridge.js +23 -8
- package/dist/session-cli-registry-bridge.js.map +1 -1
- package/dist/session-env-clamp.d.ts +1 -1
- package/dist/session-env-clamp.d.ts.map +1 -1
- package/dist/session-env-clamp.js +4 -3
- package/dist/session-env-clamp.js.map +1 -1
- package/dist/session.d.ts +161 -24
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +337 -37
- package/dist/session.js.map +1 -1
- package/dist/tmux-manager.d.ts +114 -12
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +318 -41
- package/dist/tmux-manager.js.map +1 -1
- package/dist/types/discord.d.ts +55 -0
- package/dist/types/discord.d.ts.map +1 -0
- package/dist/types/discord.js +18 -0
- package/dist/types/discord.js.map +1 -0
- package/dist/types/foreign-tmux.d.ts +101 -0
- package/dist/types/foreign-tmux.d.ts.map +1 -0
- package/dist/types/foreign-tmux.js +27 -0
- package/dist/types/foreign-tmux.js.map +1 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/session.d.ts +119 -2
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/types/update.d.ts +4 -1
- package/dist/types/update.d.ts.map +1 -1
- package/dist/web/case-path.d.ts +2 -0
- package/dist/web/case-path.d.ts.map +1 -1
- package/dist/web/case-path.js +3 -3
- package/dist/web/case-path.js.map +1 -1
- package/dist/web/edit-history-sync.d.ts +178 -0
- package/dist/web/edit-history-sync.d.ts.map +1 -0
- package/dist/web/edit-history-sync.js +461 -0
- package/dist/web/edit-history-sync.js.map +1 -0
- package/dist/web/launch-defaults.d.ts +1 -1
- package/dist/web/launch-defaults.js +2 -2
- package/dist/web/launch-defaults.js.map +1 -1
- package/dist/web/linked-cases-writer.d.ts +43 -0
- package/dist/web/linked-cases-writer.d.ts.map +1 -0
- package/dist/web/linked-cases-writer.js +107 -0
- package/dist/web/linked-cases-writer.js.map +1 -0
- package/dist/web/paste-image-gc.d.ts +10 -1
- package/dist/web/paste-image-gc.d.ts.map +1 -1
- package/dist/web/paste-image-gc.js +10 -2
- package/dist/web/paste-image-gc.js.map +1 -1
- package/dist/web/ports/session-port.d.ts +13 -0
- package/dist/web/ports/session-port.d.ts.map +1 -1
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/{app.fe8a6b4c.js → app.9ea37070.js} +6 -6
- package/dist/web/public/app.9ea37070.js.br +0 -0
- package/dist/web/public/app.9ea37070.js.gz +0 -0
- package/dist/web/public/approvals-ui.js.gz +0 -0
- package/dist/web/public/{constants.affcdde6.js → constants.782170e1.js} +114 -25
- package/dist/web/public/constants.782170e1.js.br +0 -0
- package/dist/web/public/constants.782170e1.js.gz +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/edit-history-ui.js +643 -0
- package/dist/web/public/edit-history-ui.js.br +0 -0
- package/dist/web/public/edit-history-ui.js.gz +0 -0
- package/dist/web/public/entrance-animations.js.gz +0 -0
- package/dist/web/public/foreign-sessions.js +476 -0
- package/dist/web/public/foreign-sessions.js.br +0 -0
- package/dist/web/public/foreign-sessions.js.gz +0 -0
- package/dist/web/public/git-status-ui.js +5 -3
- package/dist/web/public/git-status-ui.js.br +0 -0
- package/dist/web/public/git-status-ui.js.gz +0 -0
- package/dist/web/public/header-divider.eb2d2463.js.gz +0 -0
- package/dist/web/public/home-sessions.js.gz +0 -0
- package/dist/web/public/host-wake-ui.js.gz +0 -0
- package/dist/web/public/i18n.a5e82c95.js +2 -0
- package/dist/web/public/i18n.a5e82c95.js.br +0 -0
- package/dist/web/public/i18n.a5e82c95.js.gz +0 -0
- package/dist/web/public/image-input.a5d0f8f3.js.gz +0 -0
- package/dist/web/public/index.html +183 -18
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
- package/dist/web/public/{keyboard-accessory.ff64fafc.js → keyboard-accessory.b941676c.js} +75 -2
- package/dist/web/public/keyboard-accessory.b941676c.js.br +0 -0
- package/dist/web/public/keyboard-accessory.b941676c.js.gz +0 -0
- package/dist/web/public/mobile-handlers.f7c3f692.js.gz +0 -0
- package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
- package/dist/web/public/mobile-overview.js +10 -0
- package/dist/web/public/mobile-overview.js.br +0 -0
- package/dist/web/public/mobile-overview.js.gz +0 -0
- package/dist/web/public/mobile.67c8eb5b.css +1 -0
- package/dist/web/public/mobile.67c8eb5b.css.br +0 -0
- package/dist/web/public/mobile.67c8eb5b.css.gz +0 -0
- package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/{panels-ui.4a56ee63.js → panels-ui.8bcd4473.js} +5 -5
- package/dist/web/public/panels-ui.8bcd4473.js.br +0 -0
- package/dist/web/public/panels-ui.8bcd4473.js.gz +0 -0
- package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
- package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
- package/dist/web/public/readmymind-ui.js.gz +0 -0
- package/dist/web/public/reboot-restore-ui.js.gz +0 -0
- package/dist/web/public/{respawn-ui.d7adcf6a.js → respawn-ui.304ba0d4.js} +1 -1
- package/dist/web/public/respawn-ui.304ba0d4.js.br +0 -0
- package/dist/web/public/respawn-ui.304ba0d4.js.gz +0 -0
- package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
- package/dist/web/public/security-notices.json +1 -21
- package/dist/web/public/session-lineage.js.gz +0 -0
- package/dist/web/public/{session-ui.426fb671.js → session-ui.a7c8c095.js} +30 -30
- package/dist/web/public/session-ui.a7c8c095.js.br +0 -0
- package/dist/web/public/session-ui.a7c8c095.js.gz +0 -0
- package/dist/web/public/settings-ui.18f92052.js +106 -0
- package/dist/web/public/settings-ui.18f92052.js.br +0 -0
- package/dist/web/public/settings-ui.18f92052.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview.75c751de.js.gz +0 -0
- package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
- package/dist/web/public/styles.6a718331.css +1 -0
- package/dist/web/public/styles.6a718331.css.br +0 -0
- package/dist/web/public/styles.6a718331.css.gz +0 -0
- package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
- package/dist/web/public/sw.js +2 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/tab-layout-browser.2d6189f7.js.gz +0 -0
- package/dist/web/public/tab-rail-resize.b5cdceaf.js.gz +0 -0
- package/dist/web/public/terminal-keycode229-recovery.98843069.js.gz +0 -0
- package/dist/web/public/terminal-split.js.gz +0 -0
- package/dist/web/public/terminal-tile.js +2 -2
- package/dist/web/public/terminal-tile.js.br +0 -0
- package/dist/web/public/terminal-tile.js.gz +0 -0
- package/dist/web/public/terminal-ui.a7883640.js +4 -0
- package/dist/web/public/terminal-ui.a7883640.js.br +0 -0
- package/dist/web/public/terminal-ui.a7883640.js.gz +0 -0
- package/dist/web/public/tile-grid.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
- package/dist/web/public/vendor/fflate.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-predictive-echo.276b11d9.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.1be42307.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
- package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
- package/dist/web/public/webview-tabs.js.gz +0 -0
- package/dist/web/public/whats-new.json +9 -8
- package/dist/web/routes/case-routes.d.ts.map +1 -1
- package/dist/web/routes/case-routes.js +165 -50
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/config-backup-routes.d.ts +5 -1
- package/dist/web/routes/config-backup-routes.d.ts.map +1 -1
- package/dist/web/routes/config-backup-routes.js +4 -1
- package/dist/web/routes/config-backup-routes.js.map +1 -1
- package/dist/web/routes/custom-model-routes.d.ts +7 -2
- package/dist/web/routes/custom-model-routes.d.ts.map +1 -1
- package/dist/web/routes/custom-model-routes.js +51 -6
- package/dist/web/routes/custom-model-routes.js.map +1 -1
- package/dist/web/routes/discord-routes.d.ts +25 -0
- package/dist/web/routes/discord-routes.d.ts.map +1 -0
- package/dist/web/routes/discord-routes.js +206 -0
- package/dist/web/routes/discord-routes.js.map +1 -0
- package/dist/web/routes/edit-history-routes.d.ts +43 -0
- package/dist/web/routes/edit-history-routes.d.ts.map +1 -0
- package/dist/web/routes/edit-history-routes.js +107 -0
- package/dist/web/routes/edit-history-routes.js.map +1 -0
- package/dist/web/routes/file-routes.d.ts.map +1 -1
- package/dist/web/routes/file-routes.js +273 -102
- package/dist/web/routes/file-routes.js.map +1 -1
- package/dist/web/routes/git-status-routes.d.ts.map +1 -1
- package/dist/web/routes/git-status-routes.js +7 -2
- package/dist/web/routes/git-status-routes.js.map +1 -1
- package/dist/web/routes/index.d.ts +2 -0
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +2 -0
- package/dist/web/routes/index.js.map +1 -1
- package/dist/web/routes/mux-routes.d.ts +10 -3
- package/dist/web/routes/mux-routes.d.ts.map +1 -1
- package/dist/web/routes/mux-routes.js +59 -1
- package/dist/web/routes/mux-routes.js.map +1 -1
- package/dist/web/routes/ralph-routes.d.ts.map +1 -1
- package/dist/web/routes/ralph-routes.js +22 -0
- package/dist/web/routes/ralph-routes.js.map +1 -1
- package/dist/web/routes/readmymind-routes.d.ts.map +1 -1
- package/dist/web/routes/readmymind-routes.js +3 -2
- package/dist/web/routes/readmymind-routes.js.map +1 -1
- package/dist/web/routes/respawn-routes.d.ts.map +1 -1
- package/dist/web/routes/respawn-routes.js +24 -0
- package/dist/web/routes/respawn-routes.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts +21 -1
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +338 -55
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/routes/system-routes.d.ts.map +1 -1
- package/dist/web/routes/system-routes.js +4 -2
- package/dist/web/routes/system-routes.js.map +1 -1
- package/dist/web/schemas.d.ts +92 -2
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +111 -10
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/self-update.d.ts +9 -0
- package/dist/web/self-update.d.ts.map +1 -1
- package/dist/web/self-update.js +19 -3
- package/dist/web/self-update.js.map +1 -1
- package/dist/web/sensitive-path.d.ts.map +1 -1
- package/dist/web/sensitive-path.js +15 -0
- package/dist/web/sensitive-path.js.map +1 -1
- package/dist/web/server.d.ts +56 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +285 -17
- package/dist/web/server.js.map +1 -1
- package/dist/web/session-wait-registry.d.ts +13 -0
- package/dist/web/session-wait-registry.d.ts.map +1 -1
- package/dist/web/session-wait-registry.js +16 -8
- package/dist/web/session-wait-registry.js.map +1 -1
- package/package.json +2 -2
- package/skills/codeman/SKILL.md +20 -4
- package/dist/web/public/app.fe8a6b4c.js.br +0 -0
- package/dist/web/public/app.fe8a6b4c.js.gz +0 -0
- package/dist/web/public/constants.affcdde6.js.br +0 -0
- package/dist/web/public/constants.affcdde6.js.gz +0 -0
- package/dist/web/public/i18n.a618ef4e.js +0 -2
- package/dist/web/public/i18n.a618ef4e.js.br +0 -0
- package/dist/web/public/i18n.a618ef4e.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.ff64fafc.js.br +0 -0
- package/dist/web/public/keyboard-accessory.ff64fafc.js.gz +0 -0
- package/dist/web/public/mobile.1bbd2d67.css +0 -1
- package/dist/web/public/mobile.1bbd2d67.css.br +0 -0
- package/dist/web/public/mobile.1bbd2d67.css.gz +0 -0
- package/dist/web/public/panels-ui.4a56ee63.js.br +0 -0
- package/dist/web/public/panels-ui.4a56ee63.js.gz +0 -0
- package/dist/web/public/respawn-ui.d7adcf6a.js.br +0 -0
- package/dist/web/public/respawn-ui.d7adcf6a.js.gz +0 -0
- package/dist/web/public/session-ui.426fb671.js.br +0 -0
- package/dist/web/public/session-ui.426fb671.js.gz +0 -0
- package/dist/web/public/settings-ui.a191154e.js +0 -106
- package/dist/web/public/settings-ui.a191154e.js.br +0 -0
- package/dist/web/public/settings-ui.a191154e.js.gz +0 -0
- package/dist/web/public/styles.4f0c67a0.css +0 -1
- package/dist/web/public/styles.4f0c67a0.css.br +0 -0
- package/dist/web/public/styles.4f0c67a0.css.gz +0 -0
- package/dist/web/public/terminal-ui.7e787829.js +0 -4
- package/dist/web/public/terminal-ui.7e787829.js.br +0 -0
- package/dist/web/public/terminal-ui.7e787829.js.gz +0 -0
|
@@ -0,0 +1,1256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Two-way Discord bridge: one thread per tab, alerts out, replies typed back in.
|
|
3
|
+
*
|
|
4
|
+
* Split like webhook-notify.ts, so the parts that matter are testable without a network:
|
|
5
|
+
* - pure: `sanitiseReply`, `threadNameFor`, `buildPost`, `discordConfigProblem`, `maskToken`
|
|
6
|
+
* - store: `~/.codeman/discord.json` (bot token, 0600 via tmp+rename) and
|
|
7
|
+
* `~/.codeman/discord-bridge.json` (sessionId -> threadId, not secret)
|
|
8
|
+
* - IO: `DiscordApi` (REST, injected fetch), `DiscordGateway` (inbound WebSocket, injected
|
|
9
|
+
* socket factory) and `DiscordBridge` (the rules that tie them to sessions)
|
|
10
|
+
*
|
|
11
|
+
* Rules the code keeps and the tests pin:
|
|
12
|
+
* - The bot token lives only in `discord.json`; it is never returned, logged, or put in an error.
|
|
13
|
+
* - Only messages from `allowedUserIds`, in `guildId`, inside a thread this bridge created, are
|
|
14
|
+
* acted on. Everything else is dropped without being read further.
|
|
15
|
+
* - A reply becomes one printable line plus Enter. Embedded newlines would submit early. A lone digit
|
|
16
|
+
* while a permission or question is pending goes as the key alone (see onGatewayMessage).
|
|
17
|
+
* - Posts never ping unless `pingMode` says so, and then only the allowlisted user ids.
|
|
18
|
+
* - A Discord outage or a bad token never reaches a session: every IO path catches and backs off.
|
|
19
|
+
*
|
|
20
|
+
* @module discord-bridge
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
23
|
+
import fs from 'node:fs/promises';
|
|
24
|
+
import { randomUUID } from 'node:crypto';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { probePathKind } from './utils/bounded-path-probe.js';
|
|
27
|
+
import { DISCORD_RECEIVED_DIR } from './web/paste-image-gc.js';
|
|
28
|
+
import WebSocket from 'ws';
|
|
29
|
+
import { WEBHOOK_SCOPES } from './types/push.js';
|
|
30
|
+
import { DISCORD_ON_DELETE, DISCORD_PING_MODES, DISCORD_THREAD_CREATION, } from './types/discord.js';
|
|
31
|
+
const CONFIG_FILE = 'discord.json';
|
|
32
|
+
const THREADS_FILE = 'discord-bridge.json';
|
|
33
|
+
const API = 'https://discord.com/api/v10';
|
|
34
|
+
const GATEWAY_URL = 'wss://gateway.discord.gg/?v=10&encoding=json';
|
|
35
|
+
const SEND_TIMEOUT_MS = 8000;
|
|
36
|
+
const MAX_CONTENT = 1900;
|
|
37
|
+
const MAX_THREAD_NAME = 100;
|
|
38
|
+
/** Longest reply typed into a session; Discord allows 2000, a CLI prompt rarely needs more than this. */
|
|
39
|
+
export const MAX_REPLY_CHARS = 1000;
|
|
40
|
+
const REPLY_MIN_INTERVAL_MS = 1000;
|
|
41
|
+
const DEDUPE_WINDOW_MS = 3000;
|
|
42
|
+
/** GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT (privileged, switched on in the Developer Portal). */
|
|
43
|
+
const GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 15);
|
|
44
|
+
/** Gateway close codes that retrying cannot fix: bad token, bad intents, bad shard. */
|
|
45
|
+
const FATAL_CLOSE_CODES = new Set([4004, 4010, 4011, 4012, 4013, 4014]);
|
|
46
|
+
const SNOWFLAKE = /^\d{15,25}$/;
|
|
47
|
+
/** Stale-thread sweep: a thread younger than this may still be between creation and its map entry. */
|
|
48
|
+
const STALE_MIN_AGE_MS = 60 * 60 * 1000;
|
|
49
|
+
const STALE_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
50
|
+
/** At most this many threads closed in one sweep, so a damaged map cannot empty the channel in one go. */
|
|
51
|
+
const STALE_MAX_PER_SWEEP = 25;
|
|
52
|
+
const DISCORD_EPOCH_MS = 1420070400000;
|
|
53
|
+
/** A Markdown table wider or longer than this is posted as it was written, not padded into a code block. */
|
|
54
|
+
const TABLE_MAX_COLS = 24;
|
|
55
|
+
const TABLE_MAX_ROWS = 200;
|
|
56
|
+
/**
|
|
57
|
+
* The most of a last message worth converting: what 8 posts can carry, twice over, since a table can
|
|
58
|
+
* shrink when formatted. The rest could never be posted, and a padded table costs its widest cell
|
|
59
|
+
* times its rows, so an agent-steerable message must not reach the conversion whole.
|
|
60
|
+
*/
|
|
61
|
+
const MAX_CONVERTED_TEXT = MAX_CONTENT * 8 * 2;
|
|
62
|
+
/** Reading a CLI's transcript must not hold an alert back for long. */
|
|
63
|
+
const FULL_TEXT_TIMEOUT_MS = 4000;
|
|
64
|
+
export const DEFAULT_DISCORD_CONFIG = {
|
|
65
|
+
enabled: false,
|
|
66
|
+
token: '',
|
|
67
|
+
guildId: '',
|
|
68
|
+
parentChannelId: '',
|
|
69
|
+
allowedUserIds: [],
|
|
70
|
+
threadCreation: 'lazy',
|
|
71
|
+
onSessionDelete: 'archive',
|
|
72
|
+
staleThreads: 'keep',
|
|
73
|
+
pingMode: 'critical',
|
|
74
|
+
scope: 'attention',
|
|
75
|
+
startupHint: true,
|
|
76
|
+
};
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Pure
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
/** Why `cfg` cannot be saved as an enabled config, or null. */
|
|
81
|
+
export function discordConfigProblem(cfg) {
|
|
82
|
+
if (cfg.token && (/\s/.test(cfg.token) || cfg.token.length > 200))
|
|
83
|
+
return 'The bot token looks wrong';
|
|
84
|
+
if (cfg.guildId && !SNOWFLAKE.test(cfg.guildId))
|
|
85
|
+
return 'Guild ID must be a number (Copy ID in Discord)';
|
|
86
|
+
if (cfg.parentChannelId && !SNOWFLAKE.test(cfg.parentChannelId))
|
|
87
|
+
return 'Channel ID must be a number';
|
|
88
|
+
if (cfg.allowedUserIds.some((id) => !SNOWFLAKE.test(id)))
|
|
89
|
+
return 'Each allowed user ID must be a number';
|
|
90
|
+
if (cfg.allowedUserIds.length > 20)
|
|
91
|
+
return 'At most 20 allowed user IDs';
|
|
92
|
+
if (cfg.enabled) {
|
|
93
|
+
if (!cfg.token)
|
|
94
|
+
return 'Add the bot token before enabling';
|
|
95
|
+
if (!cfg.guildId || !cfg.parentChannelId)
|
|
96
|
+
return 'Add the guild ID and parent channel ID before enabling';
|
|
97
|
+
if (cfg.allowedUserIds.length === 0)
|
|
98
|
+
return 'Add at least one allowed user ID: nobody could reply otherwise';
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
/** First four and last two characters, so the user can tell which token is saved. */
|
|
103
|
+
export function maskToken(token) {
|
|
104
|
+
return token.length > 12 ? `${token.slice(0, 4)}•••${token.slice(-2)}` : token ? '•••' : '';
|
|
105
|
+
}
|
|
106
|
+
const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
107
|
+
/**
|
|
108
|
+
* A Discord reply as one printable line plus Enter, or null when nothing printable is left. The caller
|
|
109
|
+
* drops the Enter for a lone digit answering a pending dialog.
|
|
110
|
+
*/
|
|
111
|
+
export function sanitiseReply(raw, files = []) {
|
|
112
|
+
// eslint-disable-next-line no-control-regex
|
|
113
|
+
const line = raw.replace(/[\r\n\t]+/g, ' ').replace(/[\u0000-\u001f\u007f-\u009f]/g, '');
|
|
114
|
+
const trimmed = clip(line.trim(), MAX_REPLY_CHARS);
|
|
115
|
+
// Paths are ours (a working folder plus a sanitised name), appended after the cap so they are never cut.
|
|
116
|
+
const attached = files.length ? `${trimmed ? ' ' : ''}[attached: ${files.join(', ')}]` : '';
|
|
117
|
+
// eslint-disable-next-line no-control-regex
|
|
118
|
+
const out = `${trimmed}${attached}`.replace(/[\u0000-\u001f\u007f-\u009f]/g, '');
|
|
119
|
+
return out ? `${out}\r` : null;
|
|
120
|
+
}
|
|
121
|
+
export function threadNameFor(sessionName, sessionId) {
|
|
122
|
+
const name = (sessionName ?? '').replace(/[\r\n]+/g, ' ').trim();
|
|
123
|
+
return clip(name || sessionId.slice(0, 8), MAX_THREAD_NAME);
|
|
124
|
+
}
|
|
125
|
+
/** Whether `urgency` earns a ping under `mode`. */
|
|
126
|
+
export function shouldPing(mode, urgency, explicitUrgent = false) {
|
|
127
|
+
if (mode === 'off')
|
|
128
|
+
return false;
|
|
129
|
+
return mode === 'all' || explicitUrgent || urgency === 'critical';
|
|
130
|
+
}
|
|
131
|
+
export function shouldSendDiscord(cfg, urgency) {
|
|
132
|
+
if (!cfg.enabled || !cfg.token)
|
|
133
|
+
return false;
|
|
134
|
+
return cfg.scope === 'all' || urgency !== 'info';
|
|
135
|
+
}
|
|
136
|
+
/** Largest single file taken from Discord, or sent to it. Discord's own free limit is higher. */
|
|
137
|
+
export const MAX_FILE_BYTES = 8 * 1024 * 1024;
|
|
138
|
+
const MAX_FILES_PER_REPLY = 5;
|
|
139
|
+
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
140
|
+
/** Where a session's received files go, relative to its working folder. */
|
|
141
|
+
export const RECEIVED_DIR = DISCORD_RECEIVED_DIR;
|
|
142
|
+
/**
|
|
143
|
+
* What a session is told about the bridge. Claude gets it as `--append-system-prompt`; no other CLI
|
|
144
|
+
* is told. No apostrophes: it is rendered into a shell command.
|
|
145
|
+
*/
|
|
146
|
+
export const DISCORD_STARTUP_HINT = 'Codeman note: if you are blocked on something only the user can do (a physical step such as ' +
|
|
147
|
+
'pressing a button, an answer you cannot work out, or a wait that timed out), run: ' +
|
|
148
|
+
'codeman notify "message that leads with the action" --urgent (lead with the action). The user reads a Discord thread for ' +
|
|
149
|
+
'this tab and their reply is typed to you as one line; a file they attach is saved and its path is ' +
|
|
150
|
+
'given in that line. Add --file <path> to send a file from your working folder. Not for routine progress.';
|
|
151
|
+
let activeStartupHint;
|
|
152
|
+
/** The hint to launch Claude with, or undefined while the bridge or the setting is off. */
|
|
153
|
+
export function getDiscordStartupHint() {
|
|
154
|
+
return activeStartupHint;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The last lines of a rendered screen for a post: colour and charset codes removed, blank lines and
|
|
158
|
+
* pure rule lines (box drawing, dashes) dropped, the last 14 lines, at most 900 characters, cut on a line.
|
|
159
|
+
* With `composerGlyph` (the CLI's own prompt mark), the composer row and everything under it (the status
|
|
160
|
+
* and mode hints) is cut first, but only within the last 8 rows and never a dialog's selected option
|
|
161
|
+
* (`❯ 1. Yes`), so a question's choices stay.
|
|
162
|
+
*/
|
|
163
|
+
export function screenTail(screen, maxLines = 14, maxChars = 900, composerGlyph) {
|
|
164
|
+
let lines = screen
|
|
165
|
+
// eslint-disable-next-line no-control-regex
|
|
166
|
+
.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b[()][A-Za-z0-9]|\u001b[@-Z\\-_]/g, '')
|
|
167
|
+
// eslint-disable-next-line no-control-regex
|
|
168
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '')
|
|
169
|
+
.split(/\r?\n/)
|
|
170
|
+
.map((l) => l.trimEnd())
|
|
171
|
+
.filter((l) => l.trim() !== '' && !/^[\s\-_=\u2500-\u257f]+$/.test(l));
|
|
172
|
+
if (composerGlyph) {
|
|
173
|
+
for (let i = lines.length - 1; i >= Math.max(0, lines.length - 8); i--) {
|
|
174
|
+
const row = lines[i].trimStart();
|
|
175
|
+
if (row.startsWith(composerGlyph) && !/^\S+\s+\d+[.)]/.test(row)) {
|
|
176
|
+
lines = lines.slice(0, i);
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
let out = lines.slice(-maxLines);
|
|
182
|
+
while (out.length > 1 && out.join('\n').length > maxChars)
|
|
183
|
+
out = out.slice(1);
|
|
184
|
+
return out.join('\n').slice(-maxChars);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Discord does not render Markdown tables: a `| a | b |` block shows as raw pipes. Each table outside a
|
|
188
|
+
* code fence becomes a code block with the columns padded to their widest cell (right-aligned where the
|
|
189
|
+
* separator row says `---:`), which a monospace font lines up. Bold and code marks inside a cell are
|
|
190
|
+
* dropped, since a code block would show them literally.
|
|
191
|
+
*/
|
|
192
|
+
export function tablesToCodeBlocks(text) {
|
|
193
|
+
const cells = (row) => {
|
|
194
|
+
let r = row.trim();
|
|
195
|
+
if (r.startsWith('|'))
|
|
196
|
+
r = r.slice(1);
|
|
197
|
+
if (r.endsWith('|') && !r.endsWith('\\|'))
|
|
198
|
+
r = r.slice(0, -1);
|
|
199
|
+
return r.split(/(?<!\\)\|/).map((c) => c
|
|
200
|
+
.replace(/\\\|/g, '|')
|
|
201
|
+
.replace(/\*\*|`/g, '')
|
|
202
|
+
.trim());
|
|
203
|
+
};
|
|
204
|
+
// Whitespace stripped first, so the pattern has none to backtrack over (a run of spaces made the old one quadratic).
|
|
205
|
+
const isSeparator = (row) => {
|
|
206
|
+
const r = row.replace(/\s+/g, '');
|
|
207
|
+
return r.includes('|') && /^\|?:?-+:?(\|:?-+:?)*\|?$/.test(r);
|
|
208
|
+
};
|
|
209
|
+
const lines = text.split('\n');
|
|
210
|
+
const out = [];
|
|
211
|
+
let fence = false;
|
|
212
|
+
for (let i = 0; i < lines.length; i++) {
|
|
213
|
+
const line = lines[i];
|
|
214
|
+
if (/^\s*```/.test(line))
|
|
215
|
+
fence = !fence;
|
|
216
|
+
if (fence || !line.includes('|') || i + 1 >= lines.length || !isSeparator(lines[i + 1])) {
|
|
217
|
+
out.push(line);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
// Rows belong to the table while they carry a pipe, and start with one when the header does, so a
|
|
221
|
+
// sentence with a pipe under a table stays prose.
|
|
222
|
+
const piped = line.trim().startsWith('|');
|
|
223
|
+
let j = i + 2;
|
|
224
|
+
while (j < lines.length &&
|
|
225
|
+
lines[j].trim() !== '' &&
|
|
226
|
+
lines[j].includes('|') &&
|
|
227
|
+
(!piped || lines[j].trim().startsWith('|'))) {
|
|
228
|
+
j++;
|
|
229
|
+
}
|
|
230
|
+
const header = cells(line);
|
|
231
|
+
// A table past these bounds is left as it was (raw pipes): formatting costs columns times rows, and the
|
|
232
|
+
// text can be steered by whatever the agent read.
|
|
233
|
+
if (header.length > TABLE_MAX_COLS || j - i - 1 > TABLE_MAX_ROWS) {
|
|
234
|
+
for (let k = i; k < j; k++)
|
|
235
|
+
out.push(lines[k]); // a loop: a spread of 200,000 lines overflows the stack
|
|
236
|
+
i = j - 1;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const align = cells(lines[i + 1]).map((c) => (c.endsWith(':') ? 'right' : 'left'));
|
|
240
|
+
const rows = [header];
|
|
241
|
+
for (let k = i + 2; k < j; k++)
|
|
242
|
+
rows.push(cells(lines[k]));
|
|
243
|
+
let cols = 0;
|
|
244
|
+
for (const r of rows)
|
|
245
|
+
if (r.length > cols)
|
|
246
|
+
cols = r.length;
|
|
247
|
+
if (cols > TABLE_MAX_COLS) {
|
|
248
|
+
for (let k = i; k < j; k++)
|
|
249
|
+
out.push(lines[k]); // a loop: a spread of 200,000 lines overflows the stack
|
|
250
|
+
i = j - 1;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const width = Array.from({ length: cols }, () => 1);
|
|
254
|
+
for (const r of rows)
|
|
255
|
+
for (let c = 0; c < r.length; c++)
|
|
256
|
+
width[c] = Math.max(width[c], [...r[c]].length);
|
|
257
|
+
const pad = (v, c) => (align[c] === 'right' ? v.padStart(width[c]) : v.padEnd(width[c]));
|
|
258
|
+
const fmt = (r) => Array.from({ length: cols }, (_, c) => pad((r[c] ?? '').replace(/```/g, '\u2019\u2019\u2019'), c))
|
|
259
|
+
.join(' ')
|
|
260
|
+
.trimEnd();
|
|
261
|
+
out.push('```', fmt(header), width.map((w) => '-'.repeat(w)).join(' '), ...rows.slice(1).map(fmt), '```');
|
|
262
|
+
i = j - 1;
|
|
263
|
+
}
|
|
264
|
+
return out.join('\n');
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Split a long message into posts of at most `max` characters, on blank lines first, then lines, then
|
|
268
|
+
* (for one huge line) by length. A code fence the cut lands inside is closed and reopened, so each post
|
|
269
|
+
* renders on its own. At most `maxPosts`; the last carries a note when text is left over.
|
|
270
|
+
*/
|
|
271
|
+
export function splitForDiscord(text, max = 1900, maxPosts = 8) {
|
|
272
|
+
const posts = [];
|
|
273
|
+
let cur = '';
|
|
274
|
+
let fence = false;
|
|
275
|
+
let cut = false;
|
|
276
|
+
const flush = () => {
|
|
277
|
+
if (cur.trim())
|
|
278
|
+
posts.push(fence ? `${cur.trimEnd()}\n\`\`\`` : cur.trimEnd());
|
|
279
|
+
cur = fence ? '```\n' : '';
|
|
280
|
+
};
|
|
281
|
+
const lines = text.replace(/\r\n/g, '\n').split('\n');
|
|
282
|
+
for (let line of lines) {
|
|
283
|
+
if (posts.length >= maxPosts) {
|
|
284
|
+
cut = true;
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
while (line.length > max - 8) {
|
|
288
|
+
if (cur.trim())
|
|
289
|
+
flush();
|
|
290
|
+
cur += line.slice(0, max - 8) + '\n';
|
|
291
|
+
line = line.slice(max - 8);
|
|
292
|
+
flush();
|
|
293
|
+
if (posts.length >= maxPosts) {
|
|
294
|
+
cut = true;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (cut)
|
|
299
|
+
break;
|
|
300
|
+
if (cur.length + line.length + 1 > max - 8)
|
|
301
|
+
flush();
|
|
302
|
+
cur += `${line}\n`;
|
|
303
|
+
if (/^\s*```/.test(line))
|
|
304
|
+
fence = !fence;
|
|
305
|
+
}
|
|
306
|
+
if (!cut && cur.trim())
|
|
307
|
+
posts.push(cur.trimEnd());
|
|
308
|
+
if (cut && posts.length > 0)
|
|
309
|
+
posts[posts.length - 1] += '\n*(The message continues in the tab.)*';
|
|
310
|
+
return posts.slice(0, maxPosts);
|
|
311
|
+
}
|
|
312
|
+
/** Final path segment only, `[A-Za-z0-9._-]`, no leading dot, at most 80 characters. */
|
|
313
|
+
export function safeAttachmentName(name) {
|
|
314
|
+
const base = (name.split(/[\\/]/).pop() ?? '').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[._]+/, '');
|
|
315
|
+
return clip(base, 80) || 'file';
|
|
316
|
+
}
|
|
317
|
+
/** Discord's own CDN over https: the only place an attachment URL may point. */
|
|
318
|
+
export function isDiscordCdnUrl(raw) {
|
|
319
|
+
try {
|
|
320
|
+
const u = new URL(raw);
|
|
321
|
+
return u.protocol === 'https:' && /(^|\.)(discordapp\.com|discordapp\.net|discord\.com)$/.test(u.hostname);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
export function buildPost(msg, cfg, explicitUrgent = false) {
|
|
328
|
+
const ping = shouldPing(cfg.pingMode, msg.urgency, explicitUrgent) ? cfg.allowedUserIds : [];
|
|
329
|
+
const mentions = ping.map((id) => `<@${id}>`).join(' ');
|
|
330
|
+
const head = `${mentions ? `${mentions} ` : ''}**${clip(msg.title, 120)}**`;
|
|
331
|
+
// Built here, not by the caller, so the screen is only read for a post that is really going out.
|
|
332
|
+
let tailText;
|
|
333
|
+
try {
|
|
334
|
+
tailText = typeof msg.tail === 'function' ? msg.tail() : msg.tail;
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
tailText = undefined;
|
|
338
|
+
}
|
|
339
|
+
const fence = tailText?.trim() ? `\n\`\`\`\n${tailText.trim().replace(/```/g, "'''")}\n\`\`\`` : '';
|
|
340
|
+
const host = msg.host ? ` (${clip(msg.host, 60)})` : '';
|
|
341
|
+
const content = clip(`${head}${host}${msg.body ? `\n${msg.body}` : ''}${fence}`, MAX_CONTENT);
|
|
342
|
+
// Agent output and session names must not be able to @everyone the server; only the allowlisted ids ping.
|
|
343
|
+
return { content, allowed_mentions: { parse: [], users: ping } };
|
|
344
|
+
}
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// Store
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
export function discordConfigPath(configDir) {
|
|
349
|
+
return join(configDir, CONFIG_FILE);
|
|
350
|
+
}
|
|
351
|
+
const oneOf = (list, v, fallback) => list.includes(v) ? v : fallback;
|
|
352
|
+
function coerce(raw) {
|
|
353
|
+
const r = (typeof raw === 'object' && raw !== null ? raw : {});
|
|
354
|
+
const d = DEFAULT_DISCORD_CONFIG;
|
|
355
|
+
return {
|
|
356
|
+
enabled: r.enabled === true,
|
|
357
|
+
token: typeof r.token === 'string' ? r.token : '',
|
|
358
|
+
guildId: typeof r.guildId === 'string' ? r.guildId : '',
|
|
359
|
+
parentChannelId: typeof r.parentChannelId === 'string' ? r.parentChannelId : '',
|
|
360
|
+
allowedUserIds: Array.isArray(r.allowedUserIds)
|
|
361
|
+
? r.allowedUserIds.filter((x) => typeof x === 'string')
|
|
362
|
+
: [],
|
|
363
|
+
threadCreation: oneOf(DISCORD_THREAD_CREATION, r.threadCreation, d.threadCreation),
|
|
364
|
+
onSessionDelete: oneOf(DISCORD_ON_DELETE, r.onSessionDelete, d.onSessionDelete),
|
|
365
|
+
staleThreads: oneOf(DISCORD_ON_DELETE, r.staleThreads, d.staleThreads),
|
|
366
|
+
pingMode: oneOf(DISCORD_PING_MODES, r.pingMode, d.pingMode),
|
|
367
|
+
scope: oneOf(WEBHOOK_SCOPES, r.scope, d.scope),
|
|
368
|
+
startupHint: r.startupHint === undefined ? d.startupHint : r.startupHint === true,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
function withTimeout(p, ms) {
|
|
372
|
+
let timer;
|
|
373
|
+
return Promise.race([
|
|
374
|
+
p,
|
|
375
|
+
new Promise((_, reject) => {
|
|
376
|
+
timer = setTimeout(() => reject(new Error('timed out')), ms);
|
|
377
|
+
timer.unref?.();
|
|
378
|
+
}),
|
|
379
|
+
]).finally(() => clearTimeout(timer));
|
|
380
|
+
}
|
|
381
|
+
/** A failure with a reason worth showing as it is (unlike a network error, whose text is not). */
|
|
382
|
+
class BridgeProblem extends Error {
|
|
383
|
+
}
|
|
384
|
+
export async function readDiscordConfig(configDir) {
|
|
385
|
+
try {
|
|
386
|
+
return coerce(JSON.parse(await fs.readFile(discordConfigPath(configDir), 'utf-8')));
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return { ...DEFAULT_DISCORD_CONFIG };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async function writePrivate(target, data, mode) {
|
|
393
|
+
// A unique name: two writes at once (two admin tabs) must not share one tmp file.
|
|
394
|
+
const tmp = `${target}.${randomUUID()}.tmp`;
|
|
395
|
+
try {
|
|
396
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, 2), { mode });
|
|
397
|
+
await fs.rename(tmp, target);
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
await fs.unlink(tmp).catch(() => undefined);
|
|
401
|
+
throw err;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
/** 0600 via tmp+rename: `mode` on writeFile only applies to a file being created. */
|
|
405
|
+
export async function writeDiscordConfig(configDir, cfg) {
|
|
406
|
+
if (!existsSync(configDir))
|
|
407
|
+
mkdirSync(configDir, { recursive: true });
|
|
408
|
+
await writePrivate(discordConfigPath(configDir), coerce(cfg), 0o600);
|
|
409
|
+
}
|
|
410
|
+
/** The thread map plus the parent channel it was built under. */
|
|
411
|
+
async function readThreadFile(configDir) {
|
|
412
|
+
try {
|
|
413
|
+
const raw = JSON.parse(await fs.readFile(join(configDir, THREADS_FILE), 'utf-8'));
|
|
414
|
+
const threads = {};
|
|
415
|
+
const saved = typeof raw.threads === 'object' && raw.threads !== null ? raw.threads : {};
|
|
416
|
+
for (const [k, v] of Object.entries(saved))
|
|
417
|
+
if (typeof v === 'string' && SNOWFLAKE.test(v))
|
|
418
|
+
threads[k] = v;
|
|
419
|
+
return { parent: typeof raw.parentChannelId === 'string' ? raw.parentChannelId : '', threads };
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
return { parent: '', threads: {} };
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
export async function readThreadMap(configDir) {
|
|
426
|
+
return (await readThreadFile(configDir)).threads;
|
|
427
|
+
}
|
|
428
|
+
async function writeThreadMap(configDir, parentChannelId, threads) {
|
|
429
|
+
if (!existsSync(configDir))
|
|
430
|
+
mkdirSync(configDir, { recursive: true });
|
|
431
|
+
await writePrivate(join(configDir, THREADS_FILE), { parentChannelId, threads }, 0o600);
|
|
432
|
+
}
|
|
433
|
+
export class DiscordHttpError extends Error {
|
|
434
|
+
status;
|
|
435
|
+
code;
|
|
436
|
+
constructor(status,
|
|
437
|
+
/** Discord's own error code (10003 unknown channel, 50001 missing access ...). */
|
|
438
|
+
code) {
|
|
439
|
+
super(`Discord HTTP ${status}${code ? ` (code ${code})` : ''}`);
|
|
440
|
+
this.status = status;
|
|
441
|
+
this.code = code;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/** Thin REST client. The token goes only in the Authorization header and never into a message. */
|
|
445
|
+
export class DiscordApi {
|
|
446
|
+
token;
|
|
447
|
+
fetchImpl;
|
|
448
|
+
sleep;
|
|
449
|
+
constructor(token, fetchImpl, sleep = (ms) => new Promise((r) => setTimeout(r, ms))) {
|
|
450
|
+
this.token = token;
|
|
451
|
+
this.fetchImpl = fetchImpl;
|
|
452
|
+
this.sleep = sleep;
|
|
453
|
+
}
|
|
454
|
+
async request(method, path, body, retried = false) {
|
|
455
|
+
const isForm = typeof FormData !== 'undefined' && body instanceof FormData;
|
|
456
|
+
const res = await this.fetchImpl(new URL(`${API}${path}`), {
|
|
457
|
+
method,
|
|
458
|
+
headers: {
|
|
459
|
+
Authorization: `Bot ${this.token()}`,
|
|
460
|
+
// A form body sets its own multipart Content-Type (with the boundary).
|
|
461
|
+
...(isForm ? {} : { 'Content-Type': 'application/json' }),
|
|
462
|
+
'User-Agent': 'DiscordBot (https://github.com/Ark0N/Codeman, 1)',
|
|
463
|
+
},
|
|
464
|
+
body: body === undefined ? undefined : isForm ? body : JSON.stringify(body),
|
|
465
|
+
redirect: 'manual',
|
|
466
|
+
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
|
467
|
+
});
|
|
468
|
+
if (res.status === 429 && !retried) {
|
|
469
|
+
const j = (await res.json().catch(() => ({})));
|
|
470
|
+
await this.sleep(Math.min(Math.max((j.retry_after ?? 1) * 1000, 100), 10_000));
|
|
471
|
+
return this.request(method, path, body, true);
|
|
472
|
+
}
|
|
473
|
+
if (!res.ok) {
|
|
474
|
+
const j = (await res.json().catch(() => ({})));
|
|
475
|
+
throw new DiscordHttpError(res.status, j.code);
|
|
476
|
+
}
|
|
477
|
+
if (res.status === 204)
|
|
478
|
+
return null;
|
|
479
|
+
return (await res.json().catch(() => null));
|
|
480
|
+
}
|
|
481
|
+
createThread(parentId, name) {
|
|
482
|
+
// type 11 = public thread. A week of inactivity before Discord archives it.
|
|
483
|
+
return this.request('POST', `/channels/${parentId}/threads`, { name, type: 11, auto_archive_duration: 10080 });
|
|
484
|
+
}
|
|
485
|
+
post(channelId, p) {
|
|
486
|
+
return this.request('POST', `/channels/${channelId}/messages`, p);
|
|
487
|
+
}
|
|
488
|
+
postWithFile(channelId, p, file) {
|
|
489
|
+
const form = new FormData();
|
|
490
|
+
form.append('payload_json', JSON.stringify(p));
|
|
491
|
+
form.append('files[0]', new Blob([Buffer.from(file.data)]), safeAttachmentName(file.name));
|
|
492
|
+
return this.request('POST', `/channels/${channelId}/messages`, form);
|
|
493
|
+
}
|
|
494
|
+
rename(threadId, name) {
|
|
495
|
+
return this.request('PATCH', `/channels/${threadId}`, { name });
|
|
496
|
+
}
|
|
497
|
+
archive(threadId) {
|
|
498
|
+
return this.request('PATCH', `/channels/${threadId}`, { archived: true });
|
|
499
|
+
}
|
|
500
|
+
remove(threadId) {
|
|
501
|
+
return this.request('DELETE', `/channels/${threadId}`);
|
|
502
|
+
}
|
|
503
|
+
botUserId() {
|
|
504
|
+
return this.request('GET', '/users/@me');
|
|
505
|
+
}
|
|
506
|
+
/** Active threads of the guild (all channels: the caller filters by parent). */
|
|
507
|
+
activeThreads(guildId) {
|
|
508
|
+
return this.request('GET', `/guilds/${guildId}/threads/active`);
|
|
509
|
+
}
|
|
510
|
+
/** The most recent archived public threads of a channel (one page). */
|
|
511
|
+
archivedThreads(parentId) {
|
|
512
|
+
return this.request('GET', `/channels/${parentId}/threads/archived/public?limit=100`);
|
|
513
|
+
}
|
|
514
|
+
react(channelId, messageId, emoji) {
|
|
515
|
+
return this.request('PUT', `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const defaultSocket = (url) => new WebSocket(url);
|
|
519
|
+
/**
|
|
520
|
+
* Inbound half: identify, heartbeat, resume and reconnect with backoff. Only MESSAGE_CREATE is
|
|
521
|
+
* surfaced. A close code that retrying cannot fix (bad token, intent not enabled) stops it and
|
|
522
|
+
* records `lastError` for the Settings status line.
|
|
523
|
+
*/
|
|
524
|
+
export class DiscordGateway {
|
|
525
|
+
token;
|
|
526
|
+
onMessage;
|
|
527
|
+
socketFactory;
|
|
528
|
+
backoffMs;
|
|
529
|
+
state = 'off';
|
|
530
|
+
lastError = null;
|
|
531
|
+
sock = null;
|
|
532
|
+
seq = null;
|
|
533
|
+
sessionId = null;
|
|
534
|
+
resumeUrl = null;
|
|
535
|
+
hb = null;
|
|
536
|
+
retry = null;
|
|
537
|
+
acked = true;
|
|
538
|
+
attempts = 0;
|
|
539
|
+
stopped = true;
|
|
540
|
+
constructor(token, onMessage, socketFactory = defaultSocket, backoffMs = (n) => Math.min(1000 * 2 ** n, 60_000)) {
|
|
541
|
+
this.token = token;
|
|
542
|
+
this.onMessage = onMessage;
|
|
543
|
+
this.socketFactory = socketFactory;
|
|
544
|
+
this.backoffMs = backoffMs;
|
|
545
|
+
}
|
|
546
|
+
start() {
|
|
547
|
+
this.stopped = false;
|
|
548
|
+
this.connect();
|
|
549
|
+
}
|
|
550
|
+
stop() {
|
|
551
|
+
this.stopped = true;
|
|
552
|
+
this.state = 'off';
|
|
553
|
+
this.clearTimers();
|
|
554
|
+
const s = this.sock;
|
|
555
|
+
this.sock = null;
|
|
556
|
+
try {
|
|
557
|
+
s?.close(1000);
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
/* already closed */
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
clearTimers() {
|
|
564
|
+
if (this.hb)
|
|
565
|
+
clearInterval(this.hb);
|
|
566
|
+
if (this.retry)
|
|
567
|
+
clearTimeout(this.retry);
|
|
568
|
+
this.hb = this.retry = null;
|
|
569
|
+
}
|
|
570
|
+
connect() {
|
|
571
|
+
if (this.stopped)
|
|
572
|
+
return;
|
|
573
|
+
this.state = 'connecting';
|
|
574
|
+
let sock;
|
|
575
|
+
try {
|
|
576
|
+
sock = this.socketFactory(this.resumeUrl ?? GATEWAY_URL);
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
this.scheduleReconnect();
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
this.sock = sock;
|
|
583
|
+
sock.on('message', (data) => this.handle(sock, data.toString()));
|
|
584
|
+
sock.on('error', () => undefined); // 'close' follows and drives the reconnect
|
|
585
|
+
sock.on('close', (code) => {
|
|
586
|
+
if (this.sock !== sock)
|
|
587
|
+
return; // a replaced socket's late close
|
|
588
|
+
this.sock = null;
|
|
589
|
+
this.clearTimers();
|
|
590
|
+
if (this.stopped)
|
|
591
|
+
return;
|
|
592
|
+
if (FATAL_CLOSE_CODES.has(code)) {
|
|
593
|
+
this.state = 'error';
|
|
594
|
+
this.lastError =
|
|
595
|
+
code === 4004
|
|
596
|
+
? 'Discord refused the bot token'
|
|
597
|
+
: code === 4014
|
|
598
|
+
? 'Message Content Intent is off in the Developer Portal'
|
|
599
|
+
: `Discord closed the connection (${code})`;
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
this.scheduleReconnect();
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
scheduleReconnect() {
|
|
606
|
+
if (this.stopped)
|
|
607
|
+
return;
|
|
608
|
+
this.state = 'connecting';
|
|
609
|
+
this.retry = setTimeout(() => this.connect(), this.backoffMs(this.attempts++));
|
|
610
|
+
}
|
|
611
|
+
handle(sock, raw) {
|
|
612
|
+
// `ws` keeps emitting while a socket closes: a frame after stop() or from a replaced socket must start nothing.
|
|
613
|
+
if (this.stopped || sock !== this.sock)
|
|
614
|
+
return;
|
|
615
|
+
let p;
|
|
616
|
+
try {
|
|
617
|
+
p = JSON.parse(raw);
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (typeof p.s === 'number')
|
|
623
|
+
this.seq = p.s;
|
|
624
|
+
switch (p.op) {
|
|
625
|
+
case 10: {
|
|
626
|
+
const interval = Number(p.d?.heartbeat_interval) || 41_250;
|
|
627
|
+
this.acked = true;
|
|
628
|
+
this.hb = setInterval(() => {
|
|
629
|
+
if (!this.acked) {
|
|
630
|
+
// Zombie connection: no ack since the last beat. Resume on a fresh socket.
|
|
631
|
+
try {
|
|
632
|
+
sock.close(4000);
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
/* already closed */
|
|
636
|
+
}
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
this.acked = false;
|
|
640
|
+
sock.send(JSON.stringify({ op: 1, d: this.seq }));
|
|
641
|
+
}, interval);
|
|
642
|
+
if (this.sessionId && this.seq !== null) {
|
|
643
|
+
sock.send(JSON.stringify({ op: 6, d: { token: this.token(), session_id: this.sessionId, seq: this.seq } }));
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
sock.send(JSON.stringify({
|
|
647
|
+
op: 2,
|
|
648
|
+
d: {
|
|
649
|
+
token: this.token(),
|
|
650
|
+
intents: GATEWAY_INTENTS,
|
|
651
|
+
properties: { os: 'linux', browser: 'codeman', device: 'codeman' },
|
|
652
|
+
},
|
|
653
|
+
}));
|
|
654
|
+
}
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
case 11:
|
|
658
|
+
this.acked = true;
|
|
659
|
+
break;
|
|
660
|
+
case 1:
|
|
661
|
+
sock.send(JSON.stringify({ op: 1, d: this.seq }));
|
|
662
|
+
break;
|
|
663
|
+
case 7: // server asked us to reconnect (resumable)
|
|
664
|
+
sock.close(4000);
|
|
665
|
+
break;
|
|
666
|
+
case 9: // invalid session: resumable only if d === true
|
|
667
|
+
if (p.d !== true) {
|
|
668
|
+
this.sessionId = null;
|
|
669
|
+
this.seq = null;
|
|
670
|
+
this.resumeUrl = null;
|
|
671
|
+
}
|
|
672
|
+
sock.close(4000);
|
|
673
|
+
break;
|
|
674
|
+
case 0:
|
|
675
|
+
if (p.t === 'READY') {
|
|
676
|
+
const d = p.d;
|
|
677
|
+
this.sessionId = d.session_id ?? null;
|
|
678
|
+
this.resumeUrl = d.resume_gateway_url ? `${d.resume_gateway_url}/?v=10&encoding=json` : null;
|
|
679
|
+
this.state = 'connected';
|
|
680
|
+
this.lastError = null;
|
|
681
|
+
this.attempts = 0;
|
|
682
|
+
}
|
|
683
|
+
else if (p.t === 'RESUMED') {
|
|
684
|
+
this.state = 'connected';
|
|
685
|
+
this.attempts = 0;
|
|
686
|
+
}
|
|
687
|
+
else if (p.t === 'MESSAGE_CREATE') {
|
|
688
|
+
this.onMessage(p.d);
|
|
689
|
+
}
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
// ---------------------------------------------------------------------------
|
|
695
|
+
// The bridge
|
|
696
|
+
// ---------------------------------------------------------------------------
|
|
697
|
+
/**
|
|
698
|
+
* Create or re-verify `<workingDir>/.codeman-discord`, or null when it is not safe to write there.
|
|
699
|
+
* Same safeguards as the prompt uploads folder: the workspace is probed with the bounded probe before
|
|
700
|
+
* the first disk touch (a dead mount must not hang the event loop), a symlink or a non-directory at
|
|
701
|
+
* the folder is refused (an agent could plant `.codeman-discord -> ~/.ssh`), and the folder ignores
|
|
702
|
+
* itself with a `.gitignore` of `*`, written once with O_EXCL so an `git add -A` never commits what was
|
|
703
|
+
* sent and a file already there stays the user's.
|
|
704
|
+
*/
|
|
705
|
+
export async function ensureReceivedDir(workingDir) {
|
|
706
|
+
if ((await probePathKind(workingDir, { pastCap: true })) !== 'directory')
|
|
707
|
+
return null;
|
|
708
|
+
const dir = join(workingDir, RECEIVED_DIR);
|
|
709
|
+
try {
|
|
710
|
+
const st = await fs.lstat(dir);
|
|
711
|
+
if (st.isSymbolicLink() || !st.isDirectory())
|
|
712
|
+
return null;
|
|
713
|
+
}
|
|
714
|
+
catch (err) {
|
|
715
|
+
if (err.code !== 'ENOENT')
|
|
716
|
+
return null;
|
|
717
|
+
try {
|
|
718
|
+
await fs.mkdir(dir);
|
|
719
|
+
}
|
|
720
|
+
catch (mkErr) {
|
|
721
|
+
if (mkErr.code !== 'EEXIST')
|
|
722
|
+
return null;
|
|
723
|
+
const again = await fs.lstat(dir).catch(() => null);
|
|
724
|
+
if (!again || again.isSymbolicLink() || !again.isDirectory())
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const ignore = join(dir, '.gitignore');
|
|
729
|
+
try {
|
|
730
|
+
await fs.writeFile(ignore, '*\n', { flag: 'wx' });
|
|
731
|
+
}
|
|
732
|
+
catch (err) {
|
|
733
|
+
if (err.code === 'EEXIST')
|
|
734
|
+
return dir;
|
|
735
|
+
// An empty ignore file would read as the user's next time, so take it back.
|
|
736
|
+
await fs.rm(ignore, { force: true }).catch(() => undefined);
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
return dir;
|
|
740
|
+
}
|
|
741
|
+
export class DiscordBridge {
|
|
742
|
+
deps;
|
|
743
|
+
cfg = { ...DEFAULT_DISCORD_CONFIG };
|
|
744
|
+
threads = {};
|
|
745
|
+
loaded = false;
|
|
746
|
+
gateway = null;
|
|
747
|
+
/** The token the running gateway was started with ('' when none). */
|
|
748
|
+
gatewayToken = '';
|
|
749
|
+
lastSent = new Map();
|
|
750
|
+
lastReply = new Map();
|
|
751
|
+
/** The last name seen per session, so only a real rename touches the thread. */
|
|
752
|
+
names = new Map();
|
|
753
|
+
pending = new Map();
|
|
754
|
+
/** Tabs closed while their thread was still being created: the creation applies `onSessionDelete` to it. */
|
|
755
|
+
closedWhilePending = new Set();
|
|
756
|
+
/** The last whole message posted per tab, so the same text is not posted twice (idle, then stop). */
|
|
757
|
+
lastFull = new Map();
|
|
758
|
+
/** The parent channel the thread map was built under. */
|
|
759
|
+
threadsParent = '';
|
|
760
|
+
/** True from boot until the restored tabs exist (see hold). */
|
|
761
|
+
held = false;
|
|
762
|
+
/** Thread-map writes run one after another: concurrent creations must not interleave on the tmp file. */
|
|
763
|
+
persistChain = Promise.resolve();
|
|
764
|
+
/** Per-thread chain of inbound messages (see onGatewayMessage). */
|
|
765
|
+
inbound = new Map();
|
|
766
|
+
mapLoad;
|
|
767
|
+
sweepTimer;
|
|
768
|
+
/** The first full load (config, thread map, gateway), shared by every caller that arrives before it ends. */
|
|
769
|
+
firstLoad;
|
|
770
|
+
last = null;
|
|
771
|
+
api;
|
|
772
|
+
now;
|
|
773
|
+
constructor(deps) {
|
|
774
|
+
this.deps = deps;
|
|
775
|
+
this.now = deps.now ?? Date.now;
|
|
776
|
+
this.api = new DiscordApi(() => this.cfg.token, deps.fetchImpl, deps.sleep);
|
|
777
|
+
}
|
|
778
|
+
get lastResult() {
|
|
779
|
+
return this.last;
|
|
780
|
+
}
|
|
781
|
+
get gatewayState() {
|
|
782
|
+
return this.gateway?.state ?? 'off';
|
|
783
|
+
}
|
|
784
|
+
get gatewayError() {
|
|
785
|
+
return this.gateway?.lastError ?? null;
|
|
786
|
+
}
|
|
787
|
+
async ensureLoaded() {
|
|
788
|
+
if (this.loaded)
|
|
789
|
+
return;
|
|
790
|
+
this.firstLoad ??= this.reload();
|
|
791
|
+
await this.firstLoad;
|
|
792
|
+
}
|
|
793
|
+
/** Forget thread entries for tabs that no longer exist (dropped without a delete, for example). Once, at boot. */
|
|
794
|
+
async prune(liveIds) {
|
|
795
|
+
await this.ensureLoaded();
|
|
796
|
+
const live = new Set(liveIds);
|
|
797
|
+
if (live.size === 0)
|
|
798
|
+
return; // nothing restored: more likely a failed restore than an empty server
|
|
799
|
+
const stale = Object.keys(this.threads).filter((id) => !live.has(id));
|
|
800
|
+
if (stale.length === 0)
|
|
801
|
+
return;
|
|
802
|
+
for (const id of stale)
|
|
803
|
+
delete this.threads[id];
|
|
804
|
+
await this.persistThreads();
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Close threads in the parent channel that this bot made and no tab maps to (`staleThreads`). Runs after
|
|
808
|
+
* the boot prune, then daily. Never touches a thread someone else made (owner is the bot), a young one
|
|
809
|
+
* (its map entry may not be written yet), or anything while the map is empty (a lost map would make
|
|
810
|
+
* every thread look stale). Returns how many it archived or deleted. Never throws.
|
|
811
|
+
*/
|
|
812
|
+
async sweepStaleThreads() {
|
|
813
|
+
try {
|
|
814
|
+
await this.ensureLoaded();
|
|
815
|
+
const c = this.cfg;
|
|
816
|
+
if (c.staleThreads === 'keep' || !c.enabled || !c.token || !c.guildId || !c.parentChannelId)
|
|
817
|
+
return 0;
|
|
818
|
+
if (Object.keys(this.threads).length === 0 || this.pending.size > 0)
|
|
819
|
+
return 0;
|
|
820
|
+
const me = (await this.api.botUserId())?.id;
|
|
821
|
+
if (!me)
|
|
822
|
+
return 0;
|
|
823
|
+
const known = new Set(Object.values(this.threads));
|
|
824
|
+
const cutoff = this.now() - STALE_MIN_AGE_MS;
|
|
825
|
+
const stale = (list, archived) => (list ?? [])
|
|
826
|
+
.filter((t) => t.parent_id === c.parentChannelId &&
|
|
827
|
+
t.owner_id === me &&
|
|
828
|
+
SNOWFLAKE.test(t.id) &&
|
|
829
|
+
!known.has(t.id) &&
|
|
830
|
+
Number((BigInt(t.id) >> 22n) + BigInt(DISCORD_EPOCH_MS)) < cutoff &&
|
|
831
|
+
// An archived thread has nothing left to archive.
|
|
832
|
+
!(archived && c.staleThreads === 'archive'))
|
|
833
|
+
.map((t) => t.id);
|
|
834
|
+
const ids = [
|
|
835
|
+
...stale((await this.api.activeThreads(c.guildId))?.threads, false),
|
|
836
|
+
...(c.staleThreads === 'delete'
|
|
837
|
+
? stale((await this.api.archivedThreads(c.parentChannelId))?.threads, true)
|
|
838
|
+
: []),
|
|
839
|
+
].slice(0, STALE_MAX_PER_SWEEP);
|
|
840
|
+
let done = 0;
|
|
841
|
+
for (const id of ids) {
|
|
842
|
+
const ok = await (c.staleThreads === 'delete' ? this.api.remove(id) : this.api.archive(id)).then(() => true, () => false);
|
|
843
|
+
if (ok)
|
|
844
|
+
done++;
|
|
845
|
+
}
|
|
846
|
+
return done;
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
return 0;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
/** Sweep now, then daily. The timer does not keep the process alive and `stop()` clears it. */
|
|
853
|
+
startStaleSweep() {
|
|
854
|
+
clearInterval(this.sweepTimer);
|
|
855
|
+
void this.sweepStaleThreads();
|
|
856
|
+
this.sweepTimer = setInterval(() => void this.sweepStaleThreads(), STALE_SWEEP_INTERVAL_MS);
|
|
857
|
+
this.sweepTimer.unref?.();
|
|
858
|
+
}
|
|
859
|
+
/** Hold the gateway back (boot): a reply must not arrive before the restored tabs exist. */
|
|
860
|
+
hold() {
|
|
861
|
+
this.held = true;
|
|
862
|
+
}
|
|
863
|
+
/** Boot is done: start the gateway if the bridge is on. */
|
|
864
|
+
async release() {
|
|
865
|
+
if (!this.held)
|
|
866
|
+
return;
|
|
867
|
+
this.held = false;
|
|
868
|
+
await this.reload();
|
|
869
|
+
}
|
|
870
|
+
/** (Re)read the config and start or stop the gateway to match. Call at boot and after a save. */
|
|
871
|
+
async reload() {
|
|
872
|
+
this.cfg = await readDiscordConfig(this.deps.configDir);
|
|
873
|
+
// The thread map is read once, by whoever gets here first; everyone else waits for that read. A second
|
|
874
|
+
// read finishing later would replace the map and drop threads made in between.
|
|
875
|
+
this.mapLoad ??= readThreadFile(this.deps.configDir).then((saved) => {
|
|
876
|
+
this.threads = { ...saved.threads, ...this.threads };
|
|
877
|
+
this.threadsParent ||= saved.parent;
|
|
878
|
+
this.loaded = true;
|
|
879
|
+
});
|
|
880
|
+
await this.mapLoad;
|
|
881
|
+
// Threads belong to the channel they were made under: a new parent channel (or guild) starts afresh,
|
|
882
|
+
// or every tab would keep posting into the old one.
|
|
883
|
+
const parent = this.cfg.parentChannelId;
|
|
884
|
+
if (parent && parent !== this.threadsParent) {
|
|
885
|
+
this.threads = {};
|
|
886
|
+
this.pending.clear();
|
|
887
|
+
this.closedWhilePending.clear();
|
|
888
|
+
this.threadsParent = parent;
|
|
889
|
+
await this.persistThreads();
|
|
890
|
+
}
|
|
891
|
+
activeStartupHint = this.cfg.enabled && this.cfg.token && this.cfg.startupHint ? DISCORD_STARTUP_HINT : undefined;
|
|
892
|
+
// A save that changed neither the switch nor the token keeps the gateway session, so it can resume.
|
|
893
|
+
// A gateway that gave up (a fatal close, such as a missing intent) is replaced, or a fix could never take.
|
|
894
|
+
const want = this.cfg.enabled && this.cfg.token && !this.held ? this.cfg.token : '';
|
|
895
|
+
if (this.gateway && this.gateway.state !== 'error' && want && want === this.gatewayToken)
|
|
896
|
+
return;
|
|
897
|
+
this.gateway?.stop();
|
|
898
|
+
this.gateway = null;
|
|
899
|
+
this.gatewayToken = want;
|
|
900
|
+
if (want) {
|
|
901
|
+
this.gateway = new DiscordGateway(() => this.cfg.token, (m) => void this.onGatewayMessage(m).catch(() => undefined), this.deps.socketFactory, this.deps.backoffMs);
|
|
902
|
+
this.gateway.start();
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
stop() {
|
|
906
|
+
clearInterval(this.sweepTimer);
|
|
907
|
+
this.sweepTimer = undefined;
|
|
908
|
+
this.gateway?.stop();
|
|
909
|
+
this.gateway = null;
|
|
910
|
+
this.gatewayToken = '';
|
|
911
|
+
activeStartupHint = undefined;
|
|
912
|
+
this.lastSent.clear();
|
|
913
|
+
this.lastReply.clear();
|
|
914
|
+
this.names.clear();
|
|
915
|
+
this.lastFull.clear();
|
|
916
|
+
this.inbound.clear();
|
|
917
|
+
}
|
|
918
|
+
fail(err) {
|
|
919
|
+
const status = err instanceof DiscordHttpError ? err.status : undefined;
|
|
920
|
+
const code = err instanceof DiscordHttpError ? err.code : undefined;
|
|
921
|
+
const error = status === 401
|
|
922
|
+
? 'The bot token was refused'
|
|
923
|
+
: status === 403 || code === 50001
|
|
924
|
+
? 'The bot cannot see the parent channel (check its role and the channel ID)'
|
|
925
|
+
: status === 404
|
|
926
|
+
? 'Channel not found (check the IDs)'
|
|
927
|
+
: status
|
|
928
|
+
? `HTTP ${status}`
|
|
929
|
+
: err instanceof BridgeProblem
|
|
930
|
+
? err.message
|
|
931
|
+
: err?.name === 'TimeoutError'
|
|
932
|
+
? 'Timed out'
|
|
933
|
+
: 'Network error';
|
|
934
|
+
return { ok: false, status, error, at: this.now() };
|
|
935
|
+
}
|
|
936
|
+
persistThreads() {
|
|
937
|
+
this.persistChain = this.persistChain
|
|
938
|
+
.then(() => writeThreadMap(this.deps.configDir, this.threadsParent, { ...this.threads }))
|
|
939
|
+
.catch(() => undefined);
|
|
940
|
+
return this.persistChain;
|
|
941
|
+
}
|
|
942
|
+
/** The thread for a session, creating it when absent. Concurrent callers share one creation. */
|
|
943
|
+
threadFor(sessionId, name) {
|
|
944
|
+
const existing = this.threads[sessionId];
|
|
945
|
+
if (existing)
|
|
946
|
+
return Promise.resolve(existing);
|
|
947
|
+
const inflight = this.pending.get(sessionId);
|
|
948
|
+
if (inflight)
|
|
949
|
+
return inflight;
|
|
950
|
+
const parent = this.cfg.parentChannelId;
|
|
951
|
+
const mine = {};
|
|
952
|
+
const p = (mine.p = (async () => {
|
|
953
|
+
try {
|
|
954
|
+
const t = await this.api.createThread(parent, threadNameFor(name, sessionId));
|
|
955
|
+
if (!t?.id)
|
|
956
|
+
return this.fail(new BridgeProblem('Discord did not return a thread'));
|
|
957
|
+
// A save moved the bridge to another channel meanwhile: this thread belongs to the old one.
|
|
958
|
+
if (parent !== this.cfg.parentChannelId) {
|
|
959
|
+
await this.closeThread(t.id); // made in the old channel: tidy it
|
|
960
|
+
return this.fail(new BridgeProblem('The parent channel changed'));
|
|
961
|
+
}
|
|
962
|
+
if (this.closedWhilePending.has(sessionId)) {
|
|
963
|
+
await this.closeThread(t.id);
|
|
964
|
+
return this.fail(new BridgeProblem('The tab was closed'));
|
|
965
|
+
}
|
|
966
|
+
this.threads[sessionId] = t.id;
|
|
967
|
+
await this.persistThreads();
|
|
968
|
+
return t.id;
|
|
969
|
+
}
|
|
970
|
+
catch (err) {
|
|
971
|
+
return this.fail(err);
|
|
972
|
+
}
|
|
973
|
+
finally {
|
|
974
|
+
// Only this creation's own entry: a newer one for the same tab may have replaced it.
|
|
975
|
+
if (this.pending.get(sessionId) === mine.p) {
|
|
976
|
+
this.pending.delete(sessionId);
|
|
977
|
+
this.closedWhilePending.delete(sessionId);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
})());
|
|
981
|
+
this.pending.set(sessionId, p);
|
|
982
|
+
return p;
|
|
983
|
+
}
|
|
984
|
+
/** Post an alert. Session-less messages go to the parent channel. Never throws. */
|
|
985
|
+
async notify(msg, opts = {}) {
|
|
986
|
+
const explicitUrgent = opts.urgent === true;
|
|
987
|
+
await this.ensureLoaded();
|
|
988
|
+
if (!shouldSendDiscord(this.cfg, msg.urgency))
|
|
989
|
+
return null;
|
|
990
|
+
const key = `${msg.event}:${msg.sessionId ?? ''}`;
|
|
991
|
+
const t = this.now();
|
|
992
|
+
const prev = this.lastSent.get(key);
|
|
993
|
+
// A deliberate post (an agent's own `codeman notify`) is never deduped: a second, different message is real.
|
|
994
|
+
// Automatic events are, and say so, which is not the same answer as "the bridge is off" (null).
|
|
995
|
+
if (!opts.deliberate && prev !== undefined && t - prev < DEDUPE_WINDOW_MS) {
|
|
996
|
+
return { ok: true, deduped: true, at: t };
|
|
997
|
+
}
|
|
998
|
+
this.lastSent.set(key, t);
|
|
999
|
+
if (this.lastSent.size > 256) {
|
|
1000
|
+
for (const [k, v] of this.lastSent)
|
|
1001
|
+
if (t - v > DEDUPE_WINDOW_MS)
|
|
1002
|
+
this.lastSent.delete(k);
|
|
1003
|
+
}
|
|
1004
|
+
let chunks = [];
|
|
1005
|
+
// Remembered only once the first post is out: a failed post must leave the message to be sent again.
|
|
1006
|
+
let posted;
|
|
1007
|
+
if (msg.fullText) {
|
|
1008
|
+
try {
|
|
1009
|
+
const got = await withTimeout(msg.fullText(), FULL_TEXT_TIMEOUT_MS);
|
|
1010
|
+
const text = got?.text.trim();
|
|
1011
|
+
const sid = msg.sessionId;
|
|
1012
|
+
if (text && sid) {
|
|
1013
|
+
// Keyed on the text and the turn it came from, so a later turn that ends on the same words is posted.
|
|
1014
|
+
const key = `${got?.version ?? ''}\u0000${text}`;
|
|
1015
|
+
msg = { ...msg, tail: undefined };
|
|
1016
|
+
if (this.lastFull.get(sid) !== key) {
|
|
1017
|
+
chunks = splitForDiscord(tablesToCodeBlocks(text.slice(0, MAX_CONVERTED_TEXT)));
|
|
1018
|
+
posted = key;
|
|
1019
|
+
} // else: said already, so only the header goes
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
catch {
|
|
1023
|
+
// no transcript to read: the screen tail stands in
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
const post = buildPost(msg, this.cfg, explicitUrgent);
|
|
1027
|
+
// One post when the head and the first part fit together, the rest follow without a ping.
|
|
1028
|
+
if (chunks.length > 0 && post.content.length + 1 + chunks[0].length <= MAX_CONTENT) {
|
|
1029
|
+
post.content += `\n${chunks.shift()}`;
|
|
1030
|
+
}
|
|
1031
|
+
// Claimed just before the first post and given back if it fails, so an alert for the same tab that
|
|
1032
|
+
// overlaps this one does not post the message too, and a failed post is still sent again.
|
|
1033
|
+
let claimed;
|
|
1034
|
+
try {
|
|
1035
|
+
let target = this.cfg.parentChannelId;
|
|
1036
|
+
if (msg.sessionId) {
|
|
1037
|
+
const thread = await this.threadFor(msg.sessionId, msg.sessionName);
|
|
1038
|
+
if (typeof thread !== 'string')
|
|
1039
|
+
return (this.last = thread);
|
|
1040
|
+
target = thread;
|
|
1041
|
+
}
|
|
1042
|
+
const send = (to) => (msg.file ? this.api.postWithFile(to, post, msg.file) : this.api.post(to, post));
|
|
1043
|
+
if (posted && msg.sessionId) {
|
|
1044
|
+
claimed = { sid: msg.sessionId, before: this.lastFull.get(msg.sessionId) };
|
|
1045
|
+
this.lastFull.set(msg.sessionId, posted);
|
|
1046
|
+
}
|
|
1047
|
+
try {
|
|
1048
|
+
await send(target);
|
|
1049
|
+
}
|
|
1050
|
+
catch (err) {
|
|
1051
|
+
// The thread was deleted by hand: forget it, make a new one, once.
|
|
1052
|
+
if (msg.sessionId && err instanceof DiscordHttpError && err.status === 404) {
|
|
1053
|
+
delete this.threads[msg.sessionId];
|
|
1054
|
+
const fresh = await this.threadFor(msg.sessionId, msg.sessionName);
|
|
1055
|
+
if (typeof fresh !== 'string')
|
|
1056
|
+
return (this.last = fresh);
|
|
1057
|
+
await send(fresh);
|
|
1058
|
+
}
|
|
1059
|
+
else {
|
|
1060
|
+
throw err;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
// Parts 2 to 8 are best effort on purpose: the first post, which carries the title, is what is reported.
|
|
1064
|
+
for (const part of chunks)
|
|
1065
|
+
await this.say(this.threads[msg.sessionId ?? ''] ?? target, part);
|
|
1066
|
+
return (this.last = { ok: true, at: this.now() });
|
|
1067
|
+
}
|
|
1068
|
+
catch (err) {
|
|
1069
|
+
if (claimed) {
|
|
1070
|
+
if (claimed.before === undefined)
|
|
1071
|
+
this.lastFull.delete(claimed.sid);
|
|
1072
|
+
else
|
|
1073
|
+
this.lastFull.set(claimed.sid, claimed.before);
|
|
1074
|
+
}
|
|
1075
|
+
return (this.last = this.fail(err));
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
/** A deliberate test send to the parent channel: bypasses `enabled`, scope and dedupe. */
|
|
1079
|
+
async sendTest(cfg, host) {
|
|
1080
|
+
// Its own client: swapping this.cfg would undo a save that lands while the post is in flight.
|
|
1081
|
+
const api = new DiscordApi(() => cfg.token, this.deps.fetchImpl, this.deps.sleep);
|
|
1082
|
+
try {
|
|
1083
|
+
await api.post(cfg.parentChannelId, buildPost({
|
|
1084
|
+
event: 'discord:test',
|
|
1085
|
+
title: 'Codeman test notification',
|
|
1086
|
+
body: 'If you can read this, the Discord bridge can post. Replies in session threads are typed into that tab.',
|
|
1087
|
+
urgency: 'info',
|
|
1088
|
+
host,
|
|
1089
|
+
}, { ...cfg, pingMode: 'off' }));
|
|
1090
|
+
return (this.last = { ok: true, at: this.now() });
|
|
1091
|
+
}
|
|
1092
|
+
catch (err) {
|
|
1093
|
+
return (this.last = this.fail(err));
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
/** Session tab created: make its thread now when `threadCreation` is `onSessionStart`. */
|
|
1097
|
+
async sessionCreated(sessionId, name) {
|
|
1098
|
+
await this.ensureLoaded();
|
|
1099
|
+
if (!this.cfg.enabled || this.cfg.threadCreation !== 'onSessionStart')
|
|
1100
|
+
return;
|
|
1101
|
+
const made = await this.threadFor(sessionId, name);
|
|
1102
|
+
if (typeof made !== 'string')
|
|
1103
|
+
this.last = made;
|
|
1104
|
+
}
|
|
1105
|
+
async sessionRenamed(sessionId, name) {
|
|
1106
|
+
const seen = this.names.get(sessionId);
|
|
1107
|
+
this.names.set(sessionId, name ?? '');
|
|
1108
|
+
// First sight only records the name: the thread was made with it, so a PATCH would spend a rename for nothing.
|
|
1109
|
+
if (seen === undefined || seen === (name ?? ''))
|
|
1110
|
+
return;
|
|
1111
|
+
const thread = this.threads[sessionId];
|
|
1112
|
+
if (!thread || !this.cfg.enabled)
|
|
1113
|
+
return;
|
|
1114
|
+
await this.api.rename(thread, threadNameFor(name, sessionId)).catch(() => undefined);
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Drop the per-tab state only, keeping the thread and its map entry. For a session that is discarded
|
|
1118
|
+
* but can come back under the same id (a failed reboot-restore rebuild stays on offer for a retry).
|
|
1119
|
+
* Per-tab state goes whether or not the tab ever had a thread, so these maps cannot grow for the life of the server.
|
|
1120
|
+
*/
|
|
1121
|
+
forgetTab(sessionId) {
|
|
1122
|
+
this.lastReply.delete(sessionId);
|
|
1123
|
+
this.names.delete(sessionId);
|
|
1124
|
+
this.lastFull.delete(sessionId);
|
|
1125
|
+
}
|
|
1126
|
+
/** Session tab deleted: apply `onSessionDelete` and forget the mapping. */
|
|
1127
|
+
async sessionDeleted(sessionId) {
|
|
1128
|
+
this.forgetTab(sessionId);
|
|
1129
|
+
// A thread still being created is closed by its creation, which finds no entry to remove here.
|
|
1130
|
+
if (this.pending.has(sessionId))
|
|
1131
|
+
this.closedWhilePending.add(sessionId);
|
|
1132
|
+
const thread = this.threads[sessionId];
|
|
1133
|
+
if (!thread)
|
|
1134
|
+
return;
|
|
1135
|
+
delete this.threads[sessionId];
|
|
1136
|
+
await this.persistThreads();
|
|
1137
|
+
await this.closeThread(thread);
|
|
1138
|
+
}
|
|
1139
|
+
/** Apply `onSessionDelete` to a thread. */
|
|
1140
|
+
async closeThread(thread) {
|
|
1141
|
+
if (!this.cfg.enabled)
|
|
1142
|
+
return;
|
|
1143
|
+
if (this.cfg.onSessionDelete === 'delete')
|
|
1144
|
+
await this.api.remove(thread).catch(() => undefined);
|
|
1145
|
+
else if (this.cfg.onSessionDelete === 'archive')
|
|
1146
|
+
await this.api.archive(thread).catch(() => undefined);
|
|
1147
|
+
}
|
|
1148
|
+
say(channelId, content) {
|
|
1149
|
+
return this.api.post(channelId, { content, allowed_mentions: { parse: [], users: [] } }).catch(() => undefined);
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Save a reply's attachments under the session's working folder and return the paths. Only Discord
|
|
1153
|
+
* CDN URLs, at most 5 files of 8 MB each, names reduced to safe characters. Anything skipped is
|
|
1154
|
+
* named in `refused`, so the person is not left guessing why a file did not arrive.
|
|
1155
|
+
*/
|
|
1156
|
+
async saveAttachments(sessionId, m) {
|
|
1157
|
+
const list = m.attachments ?? [];
|
|
1158
|
+
if (list.length === 0)
|
|
1159
|
+
return { paths: [] };
|
|
1160
|
+
const dir = this.deps.sessionDir?.(sessionId);
|
|
1161
|
+
if (!dir)
|
|
1162
|
+
return { paths: [], refused: 'That tab has no local folder to save files in, so no file was saved.' };
|
|
1163
|
+
// The message id goes into a file name, so it must be a snowflake and nothing else.
|
|
1164
|
+
if (!SNOWFLAKE.test(m.id))
|
|
1165
|
+
return { paths: [], refused: 'That message could not be saved.' };
|
|
1166
|
+
const target = await ensureReceivedDir(dir);
|
|
1167
|
+
if (!target)
|
|
1168
|
+
return { paths: [], refused: 'The tab folder is not safe to write to, so no file was saved.' };
|
|
1169
|
+
const paths = [];
|
|
1170
|
+
const skipped = [];
|
|
1171
|
+
for (const [i, a] of list.entries()) {
|
|
1172
|
+
const name = safeAttachmentName(a.filename ?? 'file');
|
|
1173
|
+
if (i >= MAX_FILES_PER_REPLY || !isDiscordCdnUrl(a.url) || !(a.size <= MAX_FILE_BYTES)) {
|
|
1174
|
+
skipped.push(name);
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
try {
|
|
1178
|
+
const res = await this.deps.fetchImpl(new URL(a.url), {
|
|
1179
|
+
redirect: 'manual',
|
|
1180
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
1181
|
+
});
|
|
1182
|
+
const buf = res.ok ? new Uint8Array(await res.arrayBuffer()) : null;
|
|
1183
|
+
if (!buf || buf.byteLength > MAX_FILE_BYTES) {
|
|
1184
|
+
skipped.push(name);
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
const dest = join(target, `${m.id}-${name}`);
|
|
1188
|
+
// 'wx' (O_EXCL) also refuses a symlink planted at the name.
|
|
1189
|
+
await fs.writeFile(dest, buf, { mode: 0o600, flag: 'wx' });
|
|
1190
|
+
paths.push(dest);
|
|
1191
|
+
}
|
|
1192
|
+
catch {
|
|
1193
|
+
skipped.push(name);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
return {
|
|
1197
|
+
paths,
|
|
1198
|
+
refused: skipped.length
|
|
1199
|
+
? `Not saved (over 8 MB, more than ${MAX_FILES_PER_REPLY} files, or not downloadable): ${skipped.join(', ')}`
|
|
1200
|
+
: undefined,
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
/** Inbound reply. One chain per thread, so a reply that waits on a file download cannot be overtaken by a later one. */
|
|
1204
|
+
async onGatewayMessage(m) {
|
|
1205
|
+
const key = String(m.channel_id);
|
|
1206
|
+
const run = (this.inbound.get(key) ?? Promise.resolve()).then(() => this.handleMessage(m)).catch(() => undefined);
|
|
1207
|
+
this.inbound.set(key, run);
|
|
1208
|
+
await run;
|
|
1209
|
+
if (this.inbound.get(key) === run)
|
|
1210
|
+
this.inbound.delete(key);
|
|
1211
|
+
}
|
|
1212
|
+
/** Every gate here drops silently: a stranger learns nothing. */
|
|
1213
|
+
async handleMessage(m) {
|
|
1214
|
+
if (!this.cfg.enabled || m.author?.bot)
|
|
1215
|
+
return;
|
|
1216
|
+
if (m.guild_id !== this.cfg.guildId)
|
|
1217
|
+
return;
|
|
1218
|
+
if (!this.cfg.allowedUserIds.includes(m.author?.id))
|
|
1219
|
+
return;
|
|
1220
|
+
const sessionId = Object.keys(this.threads).find((k) => this.threads[k] === m.channel_id);
|
|
1221
|
+
if (!sessionId)
|
|
1222
|
+
return;
|
|
1223
|
+
const t = this.now();
|
|
1224
|
+
const prev = this.lastReply.get(sessionId);
|
|
1225
|
+
if (prev !== undefined && t - prev < REPLY_MIN_INTERVAL_MS) {
|
|
1226
|
+
// Too soon after the last reply: say it was not typed, instead of dropping it silently.
|
|
1227
|
+
await this.api.react(m.channel_id, m.id, '⏳').catch(() => undefined);
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
if (this.deps.agentExited?.(sessionId)) {
|
|
1231
|
+
await this.say(m.channel_id, 'The agent in that tab has exited, so nothing was typed.');
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
this.lastReply.set(sessionId, t);
|
|
1235
|
+
const saved = await this.saveAttachments(sessionId, m);
|
|
1236
|
+
const text = sanitiseReply(m.content ?? '', saved.paths);
|
|
1237
|
+
if (!text) {
|
|
1238
|
+
if (saved.refused)
|
|
1239
|
+
await this.say(m.channel_id, saved.refused);
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
if (saved.refused)
|
|
1243
|
+
await this.say(m.channel_id, saved.refused);
|
|
1244
|
+
// A lone digit answers a dialog by itself: an Enter after it would land on whatever comes next.
|
|
1245
|
+
const keys = /^\d\r$/.test(text) && this.deps.awaitingChoice?.(sessionId) ? text.slice(0, -1) : text;
|
|
1246
|
+
const ok = await this.deps.sendInput(sessionId, keys).catch(() => false);
|
|
1247
|
+
const reply = ok
|
|
1248
|
+
? this.api.react(m.channel_id, m.id, '✅')
|
|
1249
|
+
: this.api.post(m.channel_id, {
|
|
1250
|
+
content: 'That tab is gone, so nothing was typed.',
|
|
1251
|
+
allowed_mentions: { parse: [], users: [] },
|
|
1252
|
+
});
|
|
1253
|
+
await reply.catch(() => undefined);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
//# sourceMappingURL=discord-bridge.js.map
|