diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e6bcac62..f3426a34c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - Telegram: stop typing after tool results. Thanks @AbhisekBasu1 for PR #322. - Messages: stop defaulting ack reactions to 👀 when identity emoji is missing. - Auto-reply: require slash for control commands to avoid false triggers in normal text. +- Commands: unify native + text chat commands behind `commands.*` config (Discord/Slack/Telegram). Thanks @thewilloftheshadow for PR #275. - Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes. - Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure. - Gateway/CLI: stop forcing localhost URL in remote mode so remote gateway config works. Thanks @oswalpalash for PR #293. diff --git a/docs/configuration.md b/docs/configuration.md index 240a15c12..4b1b0ee7b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -409,6 +409,27 @@ Controls how inbound messages behave when an agent run is already active. } ``` +### `commands` (chat command handling) + +Controls how chat commands are enabled across connectors. + +```json5 +{ + commands: { + native: false, // register native commands when supported + text: true, // parse slash commands in chat messages + useAccessGroups: true // enforce access-group allowlists/policies for commands + } +} +``` + +Notes: +- Text commands must be sent as a **standalone** message and use the leading `/` (no plain-text aliases). +- `commands.text: false` disables parsing chat messages for commands. +- `commands.native: true` registers native commands on supported connectors (Discord/Slack/Telegram). Platforms without native commands still rely on text commands. +- `commands.native: false` skips native registration; Discord/Telegram clear previously registered commands on startup. Slack commands are managed in the Slack app. +- `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies. + ### `web` (WhatsApp web provider) WhatsApp runs through the gateway’s web provider. It starts automatically when a linked session exists. @@ -480,12 +501,6 @@ Configure the Discord bot by setting the bot token and optional gating: moderation: false }, replyToMode: "off", // off | first | all - slashCommand: { // user-installed app slash commands - enabled: true, - name: "clawd", - sessionPrefix: "discord:slash", - ephemeral: true - }, dm: { enabled: true, // disable all DMs when false policy: "pairing", // pairing | allowlist | open | disabled diff --git a/docs/discord.md b/docs/discord.md index 9c7b81de9..541b222f9 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -29,11 +29,11 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa - To ignore all DMs: set `discord.dm.enabled=false` or `discord.dm.policy="disabled"`. 8. Group DMs are ignored by default; enable via `discord.dm.groupEnabled` and optionally restrict by `discord.dm.groupChannels`. 9. Optional guild rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules. -10. Optional slash commands: enable `discord.slashCommand` to accept user-installed app commands (ephemeral replies). Slash invocations respect the same DM/guild allowlists. +10. Optional native commands: set `commands.native: true` to register native commands in Discord; set `commands.native: false` to clear previously registered native commands. Text commands are controlled by `commands.text` and must be sent as standalone `/...` messages. Use `commands.useAccessGroups: false` to bypass access-group checks for commands. 11. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable. 12. Reactions: the agent can trigger reactions via the `discord` tool (gated by `discord.actions.*`). - The `discord` tool is only exposed when the current provider is Discord. -12. Slash commands use isolated session keys (`${sessionPrefix}:${userId}`) rather than the shared `main` session. +13. Native commands use isolated session keys (`discord:slash:${userId}`) rather than the shared `main` session. Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets. Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`. @@ -63,7 +63,7 @@ In your app: **OAuth2** → **URL Generator** **Scopes** - ✅ `bot` -- ✅ `applications.commands` (only if you want slash commands; otherwise leave unchecked) +- ✅ `applications.commands` (required for native commands) **Bot Permissions** (minimal baseline) - ✅ View Channels @@ -179,12 +179,6 @@ Notes: moderation: false }, replyToMode: "off", - slashCommand: { - enabled: true, - name: "clawd", - sessionPrefix: "discord:slash", - ephemeral: true - }, dm: { enabled: true, policy: "pairing", // pairing | allowlist | open | disabled @@ -225,7 +219,6 @@ Ack reactions are controlled globally via `messages.ackReaction` + - `guilds..channels`: channel rules (keys are channel slugs or ids). - `guilds..requireMention`: per-guild mention requirement (overridable per channel). - `guilds..reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`). -- `slashCommand`: optional config for user-installed slash commands (ephemeral responses). - `mediaMaxMb`: clamp inbound media saved to disk. - `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables). - `actions`: per-action tool gates; omit to allow all (set `false` to disable). @@ -279,11 +272,9 @@ Allowlist matching notes: - Use `*` to allow any sender/channel. - When `guilds..channels` is present, channels not listed are denied by default. -Slash command notes: -- Register a chat input command in Discord with at least one string option (e.g., `prompt`). -- The first non-empty string option is treated as the prompt. -- Slash commands honor the same allowlists as DMs/guild messages (`discord.dm.allowFrom`, `discord.guilds`, per-channel rules). -- Clawdbot will auto-register `/clawd` (or the configured name) if it doesn't already exist. +Native command notes: +- The registered commands mirror Clawdbot’s chat commands. +- Native commands honor the same allowlists as DMs/guild messages (`discord.dm.allowFrom`, `discord.guilds`, per-channel rules). ## Tool actions The agent can call `discord` with actions like: diff --git a/docs/faq.md b/docs/faq.md index 9dcd422af..af5c8376d 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -572,6 +572,8 @@ Slash commands are owner-only (gated by `whatsapp.allowFrom` and command authori | `/model ` | Switch AI model (see below) | | `/queue instant\|batch\|serial` | Message queuing mode | +Commands are only recognized when the entire message is the command (slash required; no plain-text aliases). + ### How do I switch models on the fly? Use `/model` to switch without restarting: diff --git a/docs/group-messages.md b/docs/group-messages.md index 723c97ec1..e403634d2 100644 --- a/docs/group-messages.md +++ b/docs/group-messages.md @@ -12,7 +12,7 @@ Note: `routing.groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/ ## What’s implemented (2025-12-03) - Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`whatsapp.groups`) and overridden per group via `/activation`. When `whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all). - Group policy: `whatsapp.groupPolicy` controls whether group messages are accepted (`open|disabled|allowlist`). `allowlist` uses `whatsapp.groupAllowFrom` (fallback: explicit `whatsapp.allowFrom`). -- Per-group sessions: session keys look like `whatsapp:group:` so commands such as `/verbose on` or `/think:high` are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads. +- Per-group sessions: session keys look like `whatsapp:group:` so commands such as `/verbose on` or `/think high` (sent as standalone messages) are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads. - Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`. - Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking. - Ephemeral/view-once: we unwrap those before extracting text/mentions, so pings inside them still trigger. @@ -52,13 +52,13 @@ Use the group chat command: - `/activation mention` - `/activation always` -Only the owner number (from `whatsapp.allowFrom`, or the bot’s own E.164 when unset) can change this. `/status` in the group shows the current activation mode. +Only the owner number (from `whatsapp.allowFrom`, or the bot’s own E.164 when unset) can change this. Send `/status` as a standalone message in the group to see the current activation mode. ## How to use 1) Add Clawd UK (`+447700900123`) to the group. 2) Say `@clawd …` (or `@clawd uk`, `@clawdbot`, or include the number). Anyone in the group can trigger it. 3) The agent prompt will include recent group context plus the trailing `[from: …]` marker so it can address the right person. -4) Session-level directives (`/verbose on`, `/think:high`, `/new` or `/reset`, `/compact`) apply only to that group’s session; your personal DM session remains independent. +4) Session-level directives (`/verbose on`, `/think high`, `/new` or `/reset`, `/compact`) apply only to that group’s session; send them as standalone messages so they register. Your personal DM session remains independent. ## Testing / verification - Automated: `pnpm test -- src/web/auto-reply.test.ts --runInBand` (covers mention gating, history injection, sender suffix). diff --git a/docs/groups.md b/docs/groups.md index 3446936e5..98faa8547 100644 --- a/docs/groups.md +++ b/docs/groups.md @@ -110,7 +110,7 @@ Group owners can toggle per-group activation: - `/activation mention` - `/activation always` -Owner is determined by `whatsapp.allowFrom` (or the bot’s self E.164 when unset). Other surfaces currently ignore `/activation`. +Owner is determined by `whatsapp.allowFrom` (or the bot’s self E.164 when unset). Send the command as a standalone message. Other surfaces currently ignore `/activation`. ## Context fields Group inbound payloads set: diff --git a/docs/health.md b/docs/health.md index 06193277a..5647bb050 100644 --- a/docs/health.md +++ b/docs/health.md @@ -11,7 +11,7 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing. - `clawdbot status` — local summary: whether creds exist, auth age, session store path + recent sessions. - `clawdbot status --deep` — also probes the running Gateway (WhatsApp connect + Telegram + Discord APIs). - `clawdbot health --json` — asks the running Gateway for a full health snapshot (WS-only; no direct Baileys socket). -- Send `/status` in WhatsApp/WebChat to get a status reply without invoking the agent. +- Send `/status` as a standalone message in WhatsApp/WebChat to get a status reply without invoking the agent. - Logs: tail `/tmp/clawdbot/clawdbot-*.log` and filter for `web-heartbeat`, `web-reconnect`, `web-auto-reply`, `web-inbound`. ## Deep diagnostics diff --git a/docs/queue.md b/docs/queue.md index e50121229..e90da2517 100644 --- a/docs/queue.md +++ b/docs/queue.md @@ -30,7 +30,7 @@ Inbound messages can steer the current run, wait for a followup turn, or do both Steer-backlog means you can get a followup response after the steered run, so streaming surfaces can look like duplicates. Prefer `collect`/`steer` if you want one response per inbound message. -Inline fix: `/queue collect` (per-session) or set `routing.queue.byProvider.discord: "collect"`. +Send `/queue collect` as a standalone command (per-session) or set `routing.queue.byProvider.discord: "collect"`. Defaults (when unset in config): - All surfaces → `collect` @@ -61,8 +61,7 @@ Summarize keeps a short bullet list of dropped messages and injects it as a synt Defaults: `debounceMs: 1000`, `cap: 20`, `drop: summarize`. ## Per-session overrides -- `/queue ` as a standalone command stores the mode for the current session. -- `/queue ` embedded in a message applies **once** (no persistence). +- Send `/queue ` as a standalone command to store the mode for the current session. - Options can be combined: `/queue collect debounce:2s cap:25 drop:summarize` - `/queue default` or `/queue reset` clears the session override. diff --git a/docs/session-tool.md b/docs/session-tool.md index 51c3319fd..b5a5238e1 100644 --- a/docs/session-tool.md +++ b/docs/session-tool.md @@ -114,7 +114,7 @@ Policy-based blocking by provider/chat type (not per session id). Runtime override (per session entry): - `sendPolicy: "allow" | "deny"` (unset = inherit config) -- Settable via `sessions.patch` or owner-only `/send on|off|inherit`. +- Settable via `sessions.patch` or owner-only `/send on|off|inherit` (standalone message). Enforcement points: - `chat.send` / `agent` (gateway) diff --git a/docs/session.md b/docs/session.md index 03f689c4b..b72222cec 100644 --- a/docs/session.md +++ b/docs/session.md @@ -57,6 +57,7 @@ Runtime override (owner only): - `/send on` → allow for this session - `/send off` → deny for this session - `/send inherit` → clear override and use config rules +Send these as standalone messages so they register. ## Configuration (optional rename example) ```json5 @@ -76,8 +77,8 @@ Runtime override (owner only): - `pnpm clawdbot status` — shows store path and recent sessions. - `pnpm clawdbot sessions --json` — dumps every entry (filter with `--active `). - `pnpm clawdbot gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access). -- Send `/status` in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs). -- Send `/compact` (optional instructions) to summarize older context and free up window space. +- Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs). +- Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space. - JSONL transcripts can be opened directly to review full turns. ## Tips diff --git a/docs/slack.md b/docs/slack.md index 8f85eb662..6ebbac15b 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -189,6 +189,7 @@ Ack reactions are controlled globally via `messages.ackReaction` + - DMs share the `main` session (like WhatsApp/Telegram). - Channels map to `slack:channel:` sessions. - Slash commands use `slack:slash:` sessions. +- Native command registration is controlled by `commands.native`; text commands require standalone `/...` messages and can be disabled with `commands.text: false`. Slack slash commands are managed in the Slack app and are not removed automatically. Use `commands.useAccessGroups: false` to bypass access-group checks for commands. ## DM security (pairing) - Default: `slack.dm.policy="pairing"` — unknown DM senders get a pairing code. diff --git a/docs/telegram.md b/docs/telegram.md index f1f330165..85c7f27cf 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -28,6 +28,8 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup 6) Allowlist + pairing: - Direct chats: `telegram.allowFrom` (chat ids) or pairing approvals via `clawdbot pairing approve --provider telegram ` (alias: `clawdbot telegram pairing approve `). - Groups: set `telegram.groupPolicy = "allowlist"` and list senders in `telegram.groupAllowFrom` (fallback: explicit `telegram.allowFrom`). + - Commands respect group allowlists/policies by default; set `commands.useAccessGroups: false` to bypass. +7) Native commands: set `commands.native: true` to register `/` commands; set `commands.native: false` to clear previously registered commands. ## Capabilities & limits (Bot API) - Sees only messages sent after it’s added to a chat; no pre-history access. @@ -71,7 +73,7 @@ Example config: ## Group etiquette - Keep privacy mode off if you expect the bot to read all messages; with privacy on, it only sees commands/mentions. - Make the bot an admin if you need it to send in restricted groups or channels. -- Mention the bot (`@yourbot`) or use a `routing.groupChat.mentionPatterns` trigger; per-group overrides live in `telegram.groups` if you want always-on behavior. If `telegram.groups` is set, add `"*"` to keep existing allow-all behavior. +- Mention the bot (`@yourbot`), use a `routing.groupChat.mentionPatterns` trigger, or send a standalone `/...` command. Per-group overrides live in `telegram.groups` if you want always-on behavior; if `telegram.groups` is set, add `"*"` to keep existing allow-all behavior. ## Reply tags To request a threaded reply, the model can include one tag in its output: diff --git a/docs/whatsapp.md b/docs/whatsapp.md index ba614ece7..e488cc150 100644 --- a/docs/whatsapp.md +++ b/docs/whatsapp.md @@ -80,7 +80,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number - Activation modes: - `mention` (default): requires @mention or regex match. - `always`: always triggers. -- `/activation mention|always` is owner-only. +- `/activation mention|always` is owner-only and must be sent as a standalone message. - Owner = `whatsapp.allowFrom` (or self E.164 if unset). - **History injection**: - Recent messages (default 50) inserted under: diff --git a/package.json b/package.json index f246cb8e9..8dd1f0b7c 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ }, "packageManager": "pnpm@10.23.0", "dependencies": { + "@buape/carbon": "^0.13.0", "@clack/prompts": "^0.11.0", "@grammyjs/transformer-throttler": "^1.2.1", "@homebridge/ciao": "^1.3.4", @@ -102,7 +103,6 @@ "croner": "^9.1.0", "detect-libc": "^2.1.2", "discord-api-types": "^0.38.37", - "discord.js": "^14.25.1", "dotenv": "^17.2.3", "express": "^5.2.1", "file-type": "^21.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d788522d..cdaa899e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: .: dependencies: + '@buape/carbon': + specifier: ^0.13.0 + version: 0.13.0(@types/react@19.2.7)(hono@4.11.3) '@clack/prompts': specifier: ^0.11.0 version: 0.11.0 @@ -82,9 +85,6 @@ importers: discord-api-types: specifier: ^0.38.37 version: 0.38.37 - discord.js: - specifier: ^14.25.1 - version: 14.25.1 dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -331,6 +331,9 @@ packages: '@borewit/text-codec@0.2.1': resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} + '@buape/carbon@0.13.0': + resolution: {integrity: sha512-N52sGIJj832IezL+JmekC4gE7cCORj8r8mCJ1vsHOZiyr3O2pvsUA930E1j+rjStkd67TLxURPRMrpyqAFveIg==} + '@cacheable/memory@2.0.7': resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==} @@ -347,6 +350,9 @@ packages: '@clack/prompts@0.11.0': resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} + '@cloudflare/workers-types@4.20250513.0': + resolution: {integrity: sha512-TXaQyWLqhxEmi/DHx+VSaHZ4DHF/uJCPVv/hRyC7M/eWBo/I7mBtAkUEsrhqcKKO9oCeeRUHUHoeRLh5Gd96Gg==} + '@crosscopy/clipboard-darwin-arm64@0.2.8': resolution: {integrity: sha512-Y36ST9k5JZgtDE6SBT45bDNkPKBHd4UEIZgWnC0iC4kAWwdjPmsZ8Mn8e5W0YUKowJ/BDcO+EGm2tVTPQOQKXg==} engines: {node: '>= 10'} @@ -398,34 +404,6 @@ packages: resolution: {integrity: sha512-0qRWscafAHzQ+DdfXX+YgPN2KDTIzWBNfN5Q6z1CgCWsRxtkwK8HfQUc00xIejfRWSGWPIxcCTg82hvg06bodg==} engines: {node: '>= 10'} - '@discordjs/builders@1.13.1': - resolution: {integrity: sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==} - 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.0': - resolution: {integrity: sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==} - 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'} - '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -625,6 +603,12 @@ packages: resolution: {integrity: sha512-qK6ZgGx0wwOubq/MY6eTbhApQHBUQCvCOsTYpQE01uLvfA2/Prm6egySHlZouKaina1RPuDwfLhCmsRCxwHj3Q==} hasBin: true + '@hono/node-server@1.18.2': + resolution: {integrity: sha512-icgNvC0vRYivzyuSSaUv9ttcwtN8fDyd1k3AOIBDJgYd84tXRZSS6na8X54CY/oYoFTNhEmZraW/Rb9XYwX4KA==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@img/colour@1.0.0': resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} engines: {node: '>=18'} @@ -1152,18 +1136,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'} - '@sinclair/typebox@0.34.46': resolution: {integrity: sha512-kiW7CtS/NkdvTUjkjUJo7d5JsFfbJ14YjdhDk9KoEgK6nFjKNXZPrX0jfLA8ZlET4cFLHxOZ/0vFKOP+bOxIOQ==} @@ -1230,6 +1202,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/bun@1.2.23': + resolution: {integrity: sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1275,6 +1250,9 @@ packages: '@types/node@10.17.60': resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==} + '@types/node@22.19.3': + resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==} + '@types/node@25.0.3': resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} @@ -1290,6 +1268,9 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react@19.2.7': + resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} @@ -1362,10 +1343,6 @@ packages: '@vitest/utils@4.0.16': resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==} - '@vladfrangu/async_event_emitter@2.4.7': - resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==} - engines: {node: '>=v14.0.0', npm: '>=7.0.0'} - '@wasm-audio-decoders/common@9.0.7': resolution: {integrity: sha512-WRaUuWSKV7pkttBygml/a6dIEpatq2nnZGFIoPTc5yPLkxL6Wk4YaslPM98OPQvWacvNZ+Py9xROGDtrFBDzag==} @@ -1537,6 +1514,11 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bun-types@1.2.23: + resolution: {integrity: sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw==} + peerDependencies: + '@types/react': ^19 + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -1647,6 +1629,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + curve25519-js@0.0.4: resolution: {integrity: sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==} @@ -1686,13 +1671,12 @@ packages: resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} engines: {node: '>=0.3.1'} + discord-api-types@0.38.29: + resolution: {integrity: sha512-+5BfrjLJN1hrrcK0MxDQli6NSv5lQH7Y3/qaOfk9+k7itex8RkA/UcevVMMLe8B4IKIawr4ITBTb2fBB2vDORg==} + discord-api-types@0.38.37: resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==} - discord.js@14.25.1: - resolution: {integrity: sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==} - engines: {node: '>=18'} - docx-preview@0.3.7: resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==} @@ -1966,6 +1950,10 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} + hono@4.11.3: + resolution: {integrity: sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==} + engines: {node: '>=16.9.0'} + hookified@1.15.0: resolution: {integrity: sha512-51w+ZZGt7Zw5q7rM3nC4t3aLn/xvKDETsXqMczndvwyVQhAHfUmUuFBRFcos8Iyebtk7OAE9dL26wFNzZVVOkw==} @@ -2219,9 +2207,6 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - lodash.snakecase@4.1.1: - resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} - lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -2248,9 +2233,6 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true - magic-bytes.js@1.12.1: - resolution: {integrity: sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2903,9 +2885,6 @@ packages: ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} - ts-mixer@6.0.4: - resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2941,13 +2920,12 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici@6.21.3: - resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} - engines: {node: '>=18.17'} - undici@7.18.0: resolution: {integrity: sha512-CfPufgPFHCYu0W4h1NiKW9+tNJ39o3kWm7Cm29ET1enSJx+AERfz7A2wAr26aY0SZbYzZlTBQtcHy15o60VZfQ==} engines: {node: '>=20.18.1'} @@ -3088,6 +3066,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + 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 + ws@8.19.0: resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} @@ -3197,6 +3187,22 @@ snapshots: '@borewit/text-codec@0.2.1': {} + '@buape/carbon@0.13.0(@types/react@19.2.7)(hono@4.11.3)': + dependencies: + '@types/node': 22.19.3 + discord-api-types: 0.38.29 + optionalDependencies: + '@cloudflare/workers-types': 4.20250513.0 + '@hono/node-server': 1.18.2(hono@4.11.3) + '@types/bun': 1.2.23(@types/react@19.2.7) + '@types/ws': 8.18.1 + ws: 8.18.3 + transitivePeerDependencies: + - '@types/react' + - bufferutil + - hono + - utf-8-validate + '@cacheable/memory@2.0.7': dependencies: '@cacheable/utils': 2.3.3 @@ -3226,6 +3232,9 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 + '@cloudflare/workers-types@4.20250513.0': + optional: true + '@crosscopy/clipboard-darwin-arm64@0.2.8': optional: true @@ -3261,55 +3270,6 @@ snapshots: '@crosscopy/clipboard-win32-arm64-msvc': 0.2.8 '@crosscopy/clipboard-win32-x64-msvc': 0.2.8 - '@discordjs/builders@1.13.1': - dependencies: - '@discordjs/formatters': 0.6.2 - '@discordjs/util': 1.2.0 - '@sapphire/shapeshift': 4.0.0 - discord-api-types: 0.38.37 - 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.37 - - '@discordjs/rest@2.6.0': - dependencies: - '@discordjs/collection': 2.1.1 - '@discordjs/util': 1.2.0 - '@sapphire/async-queue': 1.5.5 - '@sapphire/snowflake': 3.5.3 - '@vladfrangu/async_event_emitter': 2.4.7 - discord-api-types: 0.38.37 - magic-bytes.js: 1.12.1 - tslib: 2.8.1 - undici: 6.21.3 - - '@discordjs/util@1.2.0': - dependencies: - discord-api-types: 0.38.37 - - '@discordjs/ws@1.2.3': - dependencies: - '@discordjs/collection': 2.1.1 - '@discordjs/rest': 2.6.0 - '@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.37 - tslib: 2.8.1 - ws: 8.19.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -3440,6 +3400,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@hono/node-server@1.18.2(hono@4.11.3)': + dependencies: + hono: 4.11.3 + optional: true + '@img/colour@1.0.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -3872,15 +3837,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.54.0': optional: true - '@sapphire/async-queue@1.5.5': {} - - '@sapphire/shapeshift@4.0.0': - dependencies: - fast-deep-equal: 3.1.3 - lodash: 4.17.21 - - '@sapphire/snowflake@3.5.3': {} - '@sinclair/typebox@0.34.46': {} '@slack/bolt@4.6.0(@types/express@5.0.6)': @@ -3997,6 +3953,13 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 25.0.3 + '@types/bun@1.2.23(@types/react@19.2.7)': + dependencies: + bun-types: 1.2.23(@types/react@19.2.7) + transitivePeerDependencies: + - '@types/react' + optional: true + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -4047,6 +4010,10 @@ snapshots: '@types/node@10.17.60': {} + '@types/node@22.19.3': + dependencies: + undici-types: 6.21.0 + '@types/node@25.0.3': dependencies: undici-types: 7.16.0 @@ -4061,6 +4028,11 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/react@19.2.7': + dependencies: + csstype: 3.2.3 + optional: true + '@types/retry@0.12.0': {} '@types/retry@0.12.5': {} @@ -4181,8 +4153,6 @@ snapshots: '@vitest/pretty-format': 4.0.16 tinyrainbow: 3.0.3 - '@vladfrangu/async_event_emitter@2.4.7': {} - '@wasm-audio-decoders/common@9.0.7': dependencies: '@eshaz/web-worker': 1.2.2 @@ -4377,6 +4347,12 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bun-types@1.2.23(@types/react@19.2.7): + dependencies: + '@types/node': 25.0.3 + '@types/react': 19.2.7 + optional: true + bytes@3.1.2: {} cacheable@2.3.1: @@ -4492,6 +4468,9 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: + optional: true + curve25519-js@0.0.4: {} data-uri-to-buffer@4.0.1: {} @@ -4513,26 +4492,9 @@ snapshots: diff@8.0.2: {} - discord-api-types@0.38.37: {} + discord-api-types@0.38.29: {} - discord.js@14.25.1: - dependencies: - '@discordjs/builders': 1.13.1 - '@discordjs/collection': 1.5.3 - '@discordjs/formatters': 0.6.2 - '@discordjs/rest': 2.6.0 - '@discordjs/util': 1.2.0 - '@discordjs/ws': 1.2.3 - '@sapphire/snowflake': 3.5.3 - discord-api-types: 0.38.37 - fast-deep-equal: 3.1.3 - lodash.snakecase: 4.1.1 - magic-bytes.js: 1.12.1 - tslib: 2.8.1 - undici: 6.21.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate + discord-api-types@0.38.37: {} docx-preview@0.3.7: dependencies: @@ -4871,6 +4833,9 @@ snapshots: highlight.js@11.11.1: {} + hono@4.11.3: + optional: true + hookified@1.15.0: {} html-escaper@2.0.2: {} @@ -5112,8 +5077,6 @@ snapshots: lodash.once@4.1.1: {} - lodash.snakecase@4.1.1: {} - lodash@4.17.21: {} long@4.0.0: {} @@ -5131,8 +5094,6 @@ snapshots: lz-string@1.5.0: optional: true - magic-bytes.js@1.12.1: {} - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5865,8 +5826,6 @@ snapshots: ts-algebra@2.0.0: {} - ts-mixer@6.0.4: {} - tslib@2.8.1: {} tslog@4.10.2: {} @@ -5897,9 +5856,9 @@ snapshots: uint8array-extras@1.5.0: {} - undici-types@7.16.0: {} + undici-types@6.21.0: {} - undici@6.21.3: {} + undici-types@7.16.0: {} undici@7.18.0: {} @@ -6020,6 +5979,9 @@ snapshots: wrappy@1.0.2: {} + ws@8.18.3: + optional: true + ws@8.19.0: {} y18n@5.0.8: {} diff --git a/src/auto-reply/command-detection.test.ts b/src/auto-reply/command-detection.test.ts index 5f7d758a9..755da8b12 100644 --- a/src/auto-reply/command-detection.test.ts +++ b/src/auto-reply/command-detection.test.ts @@ -32,4 +32,11 @@ describe("control command parsing", () => { expect(hasControlCommand("/status")).toBe(true); expect(hasControlCommand("status")).toBe(false); }); + + it("requires commands to be the full message", () => { + expect(hasControlCommand("hello /status")).toBe(false); + expect(hasControlCommand("/status please")).toBe(false); + expect(hasControlCommand("prefix /send on")).toBe(false); + expect(hasControlCommand("/send on")).toBe(true); + }); }); diff --git a/src/auto-reply/command-detection.ts b/src/auto-reply/command-detection.ts index df3f1104c..ae6279459 100644 --- a/src/auto-reply/command-detection.ts +++ b/src/auto-reply/command-detection.ts @@ -1,22 +1,20 @@ -const CONTROL_COMMAND_RE = - /(?:^|\s)\/(?:status|help|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new|compact)(?=$|\s|:)\b/i; - -const CONTROL_COMMAND_EXACT = new Set([ - "/help", - "/status", - "/restart", - "/activation", - "/send", - "/reset", - "/new", - "/compact", -]); +import { listChatCommands } from "./commands-registry.js"; export function hasControlCommand(text?: string): boolean { if (!text) return false; const trimmed = text.trim(); if (!trimmed) return false; const lowered = trimmed.toLowerCase(); - if (CONTROL_COMMAND_EXACT.has(lowered)) return true; - return CONTROL_COMMAND_RE.test(text); + for (const command of listChatCommands()) { + for (const alias of command.textAliases) { + const normalized = alias.trim().toLowerCase(); + if (!normalized) continue; + if (lowered === normalized) return true; + if (command.acceptsArgs && lowered.startsWith(normalized)) { + const nextChar = trimmed.charAt(normalized.length); + if (nextChar && /\s/.test(nextChar)) return true; + } + } + } + return false; } diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts new file mode 100644 index 000000000..7e07e9b81 --- /dev/null +++ b/src/auto-reply/commands-registry.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { + buildCommandText, + getCommandDetection, + listNativeCommandSpecs, + shouldHandleTextCommands, +} from "./commands-registry.js"; + +describe("commands registry", () => { + it("builds command text with args", () => { + expect(buildCommandText("status")).toBe("/status"); + expect(buildCommandText("model", "gpt-5")).toBe("/model gpt-5"); + }); + + it("exposes native specs", () => { + const specs = listNativeCommandSpecs(); + expect(specs.find((spec) => spec.name === "help")).toBeTruthy(); + }); + + it("detects known text commands", () => { + const detection = getCommandDetection(); + expect(detection.exact.has("/help")).toBe(true); + expect(detection.regex.test("/status")).toBe(true); + expect(detection.regex.test("try /status")).toBe(false); + }); + + it("respects text command gating", () => { + const cfg = { commands: { text: false } }; + expect( + shouldHandleTextCommands({ + cfg, + surface: "discord", + commandSource: "text", + }), + ).toBe(false); + expect( + shouldHandleTextCommands({ + cfg, + surface: "whatsapp", + commandSource: "text", + }), + ).toBe(true); + expect( + shouldHandleTextCommands({ + cfg, + surface: "discord", + commandSource: "native", + }), + ).toBe(true); + }); +}); diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts new file mode 100644 index 000000000..cc90e0be9 --- /dev/null +++ b/src/auto-reply/commands-registry.ts @@ -0,0 +1,178 @@ +import type { ClawdbotConfig } from "../config/types.js"; + +export type ChatCommandDefinition = { + key: string; + nativeName: string; + description: string; + textAliases: string[]; + acceptsArgs?: boolean; +}; + +export type NativeCommandSpec = { + name: string; + description: string; + acceptsArgs: boolean; +}; + +const CHAT_COMMANDS: ChatCommandDefinition[] = [ + { + key: "help", + nativeName: "help", + description: "Show available commands.", + textAliases: ["/help"], + }, + { + key: "status", + nativeName: "status", + description: "Show current status.", + textAliases: ["/status"], + }, + { + key: "restart", + nativeName: "restart", + description: "Restart Clawdbot.", + textAliases: ["/restart"], + }, + { + key: "activation", + nativeName: "activation", + description: "Set group activation mode.", + textAliases: ["/activation"], + acceptsArgs: true, + }, + { + key: "send", + nativeName: "send", + description: "Set send policy.", + textAliases: ["/send"], + acceptsArgs: true, + }, + { + key: "reset", + nativeName: "reset", + description: "Reset the current session.", + textAliases: ["/reset"], + }, + { + key: "new", + nativeName: "new", + description: "Start a new session.", + textAliases: ["/new"], + }, + { + key: "think", + nativeName: "think", + description: "Set thinking level.", + textAliases: ["/thinking", "/think", "/t"], + acceptsArgs: true, + }, + { + key: "verbose", + nativeName: "verbose", + description: "Toggle verbose mode.", + textAliases: ["/verbose", "/v"], + acceptsArgs: true, + }, + { + key: "elevated", + nativeName: "elevated", + description: "Toggle elevated mode.", + textAliases: ["/elevated", "/elev"], + acceptsArgs: true, + }, + { + key: "model", + nativeName: "model", + description: "Show or set the model.", + textAliases: ["/model"], + acceptsArgs: true, + }, + { + key: "queue", + nativeName: "queue", + description: "Adjust queue settings.", + textAliases: ["/queue"], + acceptsArgs: true, + }, +]; + +const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]); + +let cachedDetection: + | { + exact: Set; + regex: RegExp; + } + | undefined; + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function listChatCommands(): ChatCommandDefinition[] { + return [...CHAT_COMMANDS]; +} + +export function listNativeCommandSpecs(): NativeCommandSpec[] { + return CHAT_COMMANDS.map((command) => ({ + name: command.nativeName, + description: command.description, + acceptsArgs: Boolean(command.acceptsArgs), + })); +} + +export function findCommandByNativeName( + name: string, +): ChatCommandDefinition | undefined { + const normalized = name.trim().toLowerCase(); + return CHAT_COMMANDS.find( + (command) => command.nativeName.toLowerCase() === normalized, + ); +} + +export function buildCommandText(commandName: string, args?: string): string { + const trimmedArgs = args?.trim(); + return trimmedArgs ? `/${commandName} ${trimmedArgs}` : `/${commandName}`; +} + +export function getCommandDetection(): { exact: Set; regex: RegExp } { + if (cachedDetection) return cachedDetection; + const exact = new Set(); + const patterns: string[] = []; + for (const command of CHAT_COMMANDS) { + for (const alias of command.textAliases) { + const normalized = alias.trim().toLowerCase(); + if (!normalized) continue; + exact.add(normalized); + const escaped = escapeRegExp(normalized); + if (!escaped) continue; + if (command.acceptsArgs) { + patterns.push(`${escaped}(?:\\s+.+)?`); + } else { + patterns.push(escaped); + } + } + } + const regex = patterns.length + ? new RegExp(`^(?:${patterns.join("|")})$`, "i") + : /$^/; + cachedDetection = { exact, regex }; + return cachedDetection; +} + +export function supportsNativeCommands(surface?: string): boolean { + if (!surface) return false; + return NATIVE_COMMAND_SURFACES.has(surface.toLowerCase()); +} + +export function shouldHandleTextCommands(params: { + cfg: ClawdbotConfig; + surface?: string; + commandSource?: "text" | "native"; +}): boolean { + const { cfg, surface, commandSource } = params; + const textEnabled = cfg.commands?.text !== false; + if (commandSource === "native") return true; + if (textEnabled) return true; + return !supportsNativeCommands(surface); +} diff --git a/src/auto-reply/group-activation.ts b/src/auto-reply/group-activation.ts index 83f08a0d9..b60ae0e20 100644 --- a/src/auto-reply/group-activation.ts +++ b/src/auto-reply/group-activation.ts @@ -16,7 +16,7 @@ export function parseActivationCommand(raw?: string): { if (!raw) return { hasCommand: false }; const trimmed = raw.trim(); if (!trimmed) return { hasCommand: false }; - const match = trimmed.match(/^\/activation\b(?:\s+([a-zA-Z]+))?/i); + const match = trimmed.match(/^\/activation(?:\s+([a-zA-Z]+))?\s*$/i); if (!match) return { hasCommand: false }; const mode = normalizeGroupActivation(match[1]); return { hasCommand: true, mode }; diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index bfbc290ca..55004ce9c 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -512,7 +512,7 @@ describe("directive parsing", () => { await withTempHome(async (home) => { const storePath = path.join(home, "sessions.json"); const ctx = { - Body: "please do the thing /verbose on", + Body: "please do the thing", From: "+1004", To: "+2000", }; @@ -546,6 +546,21 @@ describe("directive parsing", () => { }; }); + await getReplyFromConfig( + { Body: "/verbose on", From: ctx.From, To: ctx.To }, + {}, + { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, + whatsapp: { + allowFrom: ["*"], + }, + session: { store: storePath }, + }, + ); + const res = await getReplyFromConfig( ctx, {}, @@ -827,7 +842,7 @@ describe("directive parsing", () => { }); }); - it("uses model override for inline /model", async () => { + it("ignores inline /model and uses the default model", async () => { await withTempHome(async (home) => { const storePath = path.join(home, "sessions.json"); vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ @@ -867,8 +882,8 @@ describe("directive parsing", () => { expect(texts).toContain("done"); expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; - expect(call?.provider).toBe("openai"); - expect(call?.model).toBe("gpt-4.1-mini"); + expect(call?.provider).toBe("anthropic"); + expect(call?.model).toBe("claude-opus-4-5"); }); }); diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 62855043c..f3efa1368 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -115,8 +115,15 @@ describe("trigger handling", () => { }); }); - it("reports status when /status appears inline", async () => { + it("ignores inline /status and runs the agent", async () => { await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); const res = await getReplyFromConfig( { Body: "please /status now", @@ -127,8 +134,8 @@ describe("trigger handling", () => { makeCfg(home), ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Status"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(text).not.toContain("Status"); + expect(runEmbeddedPiAgent).toHaveBeenCalled(); }); }); @@ -265,8 +272,15 @@ describe("trigger handling", () => { }); }); - it("rejects elevated inline directive for unapproved sender", async () => { + it("ignores inline elevated directive for unapproved sender", async () => { await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); const cfg = { agent: { model: "anthropic/claude-opus-4-5", @@ -293,8 +307,8 @@ describe("trigger handling", () => { cfg, ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBe("elevated is not available right now."); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(text).not.toBe("elevated is not available right now."); + expect(runEmbeddedPiAgent).toHaveBeenCalled(); }); }); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index c27395eb4..34c8b0115 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -31,6 +31,7 @@ import { clearCommandLane, getQueueSize } from "../process/command-queue.js"; import { defaultRuntime } from "../runtime.js"; import { resolveCommandAuthorization } from "./command-auth.js"; import { hasControlCommand } from "./command-detection.js"; +import { shouldHandleTextCommands } from "./commands-registry.js"; import { getAbortMemory } from "./reply/abort.js"; import { runReplyAgent } from "./reply/agent-runner.js"; import { resolveBlockStreamingChunking } from "./reply/block-streaming.js"; @@ -38,6 +39,7 @@ import { applySessionHints } from "./reply/body.js"; import { buildCommandContext, handleCommands } from "./reply/commands.js"; import { handleDirectiveOnly, + type InlineDirectives, isDirectiveOnly, parseInlineDirectives, persistInlineDirectives, @@ -48,7 +50,7 @@ import { defaultGroupActivation, resolveGroupRequireMention, } from "./reply/groups.js"; -import { stripMentions } from "./reply/mentions.js"; +import { stripMentions, stripStructuralPrefixes } from "./reply/mentions.js"; import { createModelSelectionState, resolveContextTokens, @@ -83,9 +85,6 @@ export type { GetReplyOptions, ReplyPayload } from "./types.js"; const BARE_SESSION_RESET_PROMPT = "A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. Do not mention internal steps, files, tools, or reasoning."; -const CONTROL_COMMAND_PREFIX_RE = - /^\/(?:status|help|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new|compact)\b/i; - function normalizeAllowToken(value?: string) { if (!value) return ""; return value.trim().toLowerCase(); @@ -254,7 +253,7 @@ export async function getReplyFromConfig( } const commandAuthorized = ctx.CommandAuthorized ?? true; - const commandAuth = resolveCommandAuthorization({ + resolveCommandAuthorization({ ctx, cfg, commandAuthorized, @@ -281,7 +280,47 @@ export async function getReplyFromConfig( } = sessionState; const rawBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; - const parsedDirectives = parseInlineDirectives(rawBody); + const clearInlineDirectives = (cleaned: string): InlineDirectives => ({ + cleaned, + hasThinkDirective: false, + thinkLevel: undefined, + rawThinkLevel: undefined, + hasVerboseDirective: false, + verboseLevel: undefined, + rawVerboseLevel: undefined, + hasElevatedDirective: false, + elevatedLevel: undefined, + rawElevatedLevel: undefined, + hasStatusDirective: false, + hasModelDirective: false, + rawModelDirective: undefined, + hasQueueDirective: false, + queueMode: undefined, + queueReset: false, + rawQueueMode: undefined, + debounceMs: undefined, + cap: undefined, + dropPolicy: undefined, + rawDebounce: undefined, + rawCap: undefined, + rawDrop: undefined, + hasQueueOptions: false, + }); + let parsedDirectives = parseInlineDirectives(rawBody); + const hasDirective = + parsedDirectives.hasThinkDirective || + parsedDirectives.hasVerboseDirective || + parsedDirectives.hasElevatedDirective || + parsedDirectives.hasStatusDirective || + parsedDirectives.hasModelDirective || + parsedDirectives.hasQueueDirective; + if (hasDirective) { + const stripped = stripStructuralPrefixes(parsedDirectives.cleaned); + const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped; + if (noMentions.trim().length > 0) { + parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned); + } + } const directives = commandAuthorized ? parsedDirectives : { @@ -468,6 +507,11 @@ export async function getReplyFromConfig( triggerBodyNormalized, commandAuthorized, }); + const allowTextCommands = shouldHandleTextCommands({ + cfg, + surface: command.surface, + commandSource: ctx.CommandSource, + }); const isEmptyConfig = Object.keys(cfg).length === 0; if ( command.isWhatsAppProvider && @@ -538,20 +582,15 @@ export async function getReplyFromConfig( const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; const rawBodyTrimmed = (ctx.Body ?? "").trim(); const baseBodyTrimmedRaw = baseBody.trim(); - const strippedCommandBody = isGroup - ? stripMentions(triggerBodyNormalized, ctx, cfg) - : triggerBodyNormalized; if ( - !commandAuth.isAuthorizedSender && - CONTROL_COMMAND_PREFIX_RE.test(strippedCommandBody.trim()) + allowTextCommands && + !commandAuthorized && + !baseBodyTrimmedRaw && + hasControlCommand(rawBody) ) { typing.cleanup(); return undefined; } - if (!commandAuthorized && !baseBodyTrimmedRaw && hasControlCommand(rawBody)) { - typing.cleanup(); - return undefined; - } const isBareSessionReset = isNewSession && baseBodyTrimmedRaw.length === 0 && diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index b9a560f69..f7b7e7122 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -27,6 +27,7 @@ import { normalizeE164 } from "../../utils.js"; import { resolveHeartbeatSeconds } from "../../web/reconnect.js"; import { getWebAuthAgeMs, webAuthExists } from "../../web/session.js"; import { resolveCommandAuthorization } from "../command-auth.js"; +import { shouldHandleTextCommands } from "../commands-registry.js"; import { normalizeGroupActivation, parseActivationCommand, @@ -47,6 +48,7 @@ import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; import { incrementCompactionCount } from "./session-updates.js"; export type CommandContext = { + surface: string; provider: string; isWhatsAppProvider: boolean; ownerList: string[]; @@ -123,7 +125,8 @@ export function buildCommandContext(params: { cfg, commandAuthorized: params.commandAuthorized, }); - const provider = (ctx.Provider ?? "").trim().toLowerCase(); + const surface = (ctx.Surface ?? ctx.Provider ?? "").trim().toLowerCase(); + const provider = (ctx.Provider ?? surface).trim().toLowerCase(); const abortKey = sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined); const rawBodyNormalized = triggerBodyNormalized; @@ -132,6 +135,7 @@ export function buildCommandContext(params: { : rawBodyNormalized; return { + surface, provider, isWhatsAppProvider: auth.isWhatsAppProvider, ownerList: auth.ownerList, @@ -207,8 +211,13 @@ export async function handleCommands(params: { const sendPolicyCommand = parseSendPolicyCommand( command.commandBodyNormalized, ); + const allowTextCommands = shouldHandleTextCommands({ + cfg, + surface: command.surface, + commandSource: ctx.CommandSource, + }); - if (activationCommand.hasCommand) { + if (allowTextCommands && activationCommand.hasCommand) { if (!isGroup) { return { shouldContinue: false, @@ -255,7 +264,7 @@ export async function handleCommands(params: { }; } - if (sendPolicyCommand.hasCommand) { + if (allowTextCommands && sendPolicyCommand.hasCommand) { if (!command.isAuthorizedSender) { logVerbose( `Ignoring /send from unauthorized sender: ${command.senderE164 || ""}`, @@ -292,10 +301,7 @@ export async function handleCommands(params: { }; } - if ( - command.commandBodyNormalized === "/restart" || - command.commandBodyNormalized.startsWith("/restart ") - ) { + if (allowTextCommands && command.commandBodyNormalized === "/restart") { if (!command.isAuthorizedSender) { logVerbose( `Ignoring /restart from unauthorized sender: ${command.senderE164 || ""}`, @@ -311,10 +317,8 @@ export async function handleCommands(params: { }; } - const helpRequested = - command.commandBodyNormalized === "/help" || - /(?:^|\s)\/help(?=$|\s|:)\b/i.test(command.commandBodyNormalized); - if (helpRequested) { + const helpRequested = command.commandBodyNormalized === "/help"; + if (allowTextCommands && helpRequested) { if (!command.isAuthorizedSender) { logVerbose( `Ignoring /help from unauthorized sender: ${command.senderE164 || ""}`, @@ -326,9 +330,8 @@ export async function handleCommands(params: { const statusRequested = directives.hasStatusDirective || - command.commandBodyNormalized === "/status" || - command.commandBodyNormalized.startsWith("/status "); - if (statusRequested) { + command.commandBodyNormalized === "/status"; + if (allowTextCommands && statusRequested) { if (!command.isAuthorizedSender) { logVerbose( `Ignoring /status from unauthorized sender: ${command.senderE164 || ""}`, @@ -451,7 +454,7 @@ export async function handleCommands(params: { } const abortRequested = isAbortTrigger(command.rawBodyNormalized); - if (abortRequested) { + if (allowTextCommands && abortRequested) { if (sessionEntry && sessionStore && sessionKey) { sessionEntry.abortedLastRun = true; sessionEntry.updatedAt = Date.now(); diff --git a/src/auto-reply/send-policy.ts b/src/auto-reply/send-policy.ts index e7fb95d4c..272720949 100644 --- a/src/auto-reply/send-policy.ts +++ b/src/auto-reply/send-policy.ts @@ -17,7 +17,7 @@ export function parseSendPolicyCommand(raw?: string): { if (!raw) return { hasCommand: false }; const trimmed = raw.trim(); if (!trimmed) return { hasCommand: false }; - const match = trimmed.match(/^\/send\b(?:\s+([a-zA-Z]+))?/i); + const match = trimmed.match(/^\/send(?:\s+([a-zA-Z]+))?\s*$/i); if (!match) return { hasCommand: false }; const token = match[1]?.trim().toLowerCase(); if (!token) return { hasCommand: true }; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 369cf9138..5b229c076 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -28,8 +28,11 @@ export type MsgContext = { SenderE164?: string; /** Provider label (whatsapp|telegram|discord|imessage|...). */ Provider?: string; + /** Provider surface label (e.g. discord, slack). Prefer this over `Provider` when available. */ + Surface?: string; WasMentioned?: boolean; CommandAuthorized?: boolean; + CommandSource?: "text" | "native"; }; export type TemplateContext = MsgContext & { diff --git a/src/config/schema.ts b/src/config/schema.ts index 3696fdeac..1e718cd75 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -32,6 +32,7 @@ const GROUP_LABELS: Record = { models: "Models", routing: "Routing", messages: "Messages", + commands: "Commands", session: "Session", cron: "Cron", hooks: "Hooks", @@ -58,6 +59,7 @@ const GROUP_ORDER: Record = { models: 50, routing: 60, messages: 70, + commands: 75, session: 80, cron: 90, hooks: 100, @@ -94,6 +96,9 @@ const FIELD_LABELS: Record = { "agent.model.fallbacks": "Model Fallbacks", "agent.imageModel.primary": "Image Model", "agent.imageModel.fallbacks": "Image Model Fallbacks", + "commands.native": "Native Commands", + "commands.text": "Text Commands", + "commands.useAccessGroups": "Use Access Groups", "ui.seamColor": "Accent Color", "browser.controlUrl": "Browser Control URL", "session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns", @@ -137,6 +142,11 @@ const FIELD_HELP: Record = { "Optional image model (provider/model) used when the primary model lacks image input.", "agent.imageModel.fallbacks": "Ordered fallback image models (provider/model).", + "commands.native": + "Register native commands with connectors that support it (Discord/Slack/Telegram).", + "commands.text": "Allow text command parsing (slash commands only).", + "commands.useAccessGroups": + "Enforce access-group allowlists/policies for commands.", "session.agentToAgent.maxPingPongTurns": "Max reply-back turns between requester and target (0–5).", "messages.ackReaction": diff --git a/src/config/types.ts b/src/config/types.ts index 4066bf533..0f76c3beb 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -300,17 +300,6 @@ export type DiscordGuildEntry = { channels?: Record; }; -export type DiscordSlashCommandConfig = { - /** Enable handling for the configured slash command (default: false). */ - enabled?: boolean; - /** Slash command name (default: "clawd"). */ - name?: string; - /** Session key prefix for slash commands (default: "discord:slash"). */ - sessionPrefix?: string; - /** Reply ephemerally (default: true). */ - ephemeral?: boolean; -}; - export type DiscordActionConfig = { reactions?: boolean; stickers?: boolean; @@ -350,7 +339,6 @@ export type DiscordConfig = { actions?: DiscordActionConfig; /** Control reply threading when reply tags are present (off|first|all). */ replyToMode?: ReplyToMode; - slashCommand?: DiscordSlashCommandConfig; dm?: DiscordDmConfig; /** New per-guild config keyed by guild id or slug. */ guilds?: Record; @@ -577,6 +565,15 @@ export type MessagesConfig = { ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all"; }; +export type CommandsConfig = { + /** Enable native command registration when supported (default: false). */ + native?: boolean; + /** Enable text command parsing (default: true). */ + text?: boolean; + /** Enforce access-group allowlists/policies for commands (default: true). */ + useAccessGroups?: boolean; +}; + export type BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback"; export type BridgeConfig = { @@ -998,6 +995,7 @@ export type ClawdbotConfig = { }; routing?: RoutingConfig; messages?: MessagesConfig; + commands?: CommandsConfig; session?: SessionConfig; web?: WebConfig; whatsapp?: WhatsAppConfig; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index ce87b9362..acf134e1c 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -165,6 +165,14 @@ const MessagesSchema = z }) .optional(); +const CommandsSchema = z + .object({ + native: z.boolean().optional(), + text: z.boolean().optional(), + useAccessGroups: z.boolean().optional(), + }) + .optional(); + const HeartbeatSchema = z .object({ every: z.string().optional(), @@ -632,6 +640,7 @@ export const ClawdbotSchema = z.object({ .optional(), routing: RoutingSchema, messages: MessagesSchema, + commands: CommandsSchema, session: SessionSchema, cron: z .object({ @@ -786,14 +795,6 @@ export const ClawdbotSchema = z.object({ token: z.string().optional(), groupPolicy: GroupPolicySchema.optional().default("open"), textChunkLimit: z.number().int().positive().optional(), - slashCommand: z - .object({ - enabled: z.boolean().optional(), - name: z.string().optional(), - sessionPrefix: z.string().optional(), - ephemeral: z.boolean().optional(), - }) - .optional(), mediaMaxMb: z.number().positive().optional(), historyLimit: z.number().int().min(0).optional(), actions: z diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts index 99dd7e4c0..f0925e734 100644 --- a/src/discord/monitor.test.ts +++ b/src/discord/monitor.test.ts @@ -1,3 +1,4 @@ +import type { Guild } from "@buape/carbon"; import { describe, expect, it } from "vitest"; import { allowListMatches, @@ -12,8 +13,7 @@ import { shouldEmitDiscordReactionNotification, } from "./monitor.js"; -const fakeGuild = (id: string, name: string) => - ({ id, name }) as unknown as import("discord.js").Guild; +const fakeGuild = (id: string, name: string) => ({ id, name }) as Guild; const makeEntries = ( entries: Record>, diff --git a/src/discord/monitor.tool-result.test.ts b/src/discord/monitor.tool-result.test.ts index c3d8f7186..310c07e82 100644 --- a/src/discord/monitor.tool-result.test.ts +++ b/src/discord/monitor.tool-result.test.ts @@ -1,267 +1,170 @@ +import type { Client } from "@buape/carbon"; +import { ChannelType, MessageType } from "@buape/carbon"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { monitorDiscordProvider } from "./monitor.js"; - const sendMock = vi.fn(); -const replyMock = vi.fn(); const updateLastRouteMock = vi.fn(); -let config: Record = {}; -const readAllowFromStoreMock = vi.fn(); -const upsertPairingRequestMock = vi.fn(); - -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => config, - }; -}); - -vi.mock("../auto-reply/reply.js", () => ({ - getReplyFromConfig: (...args: unknown[]) => replyMock(...args), -})); +const dispatchMock = vi.fn(); vi.mock("./send.js", () => ({ sendMessageDiscord: (...args: unknown[]) => sendMock(...args), })); - -vi.mock("../pairing/pairing-store.js", () => ({ - readProviderAllowFromStore: (...args: unknown[]) => - readAllowFromStoreMock(...args), - upsertProviderPairingRequest: (...args: unknown[]) => - upsertPairingRequestMock(...args), +vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({ + dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args), })); - -vi.mock("../config/sessions.js", () => ({ - resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"), - updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), - resolveSessionKey: vi.fn(), -})); - -vi.mock("discord.js", () => { - const handlers = new Map void>>(); - class Client { - static lastClient: Client | null = null; - user = { id: "bot-id", tag: "bot#1" }; - constructor() { - Client.lastClient = this; - } - on(event: string, handler: (...args: unknown[]) => void) { - if (!handlers.has(event)) handlers.set(event, new Set()); - handlers.get(event)?.add(handler); - } - once(event: string, handler: (...args: unknown[]) => void) { - this.on(event, handler); - } - off(event: string, handler: (...args: unknown[]) => void) { - handlers.get(event)?.delete(handler); - } - emit(event: string, ...args: unknown[]) { - for (const handler of handlers.get(event) ?? []) { - Promise.resolve(handler(...args)).catch(() => {}); - } - } - login = vi.fn().mockResolvedValue(undefined); - destroy = vi.fn().mockImplementation(async () => { - handlers.clear(); - Client.lastClient = null; - }); - } - +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); return { - Client, - __getLastClient: () => Client.lastClient, - Events: { - ClientReady: "ready", - Error: "error", - MessageCreate: "messageCreate", - MessageReactionAdd: "reactionAdd", - MessageReactionRemove: "reactionRemove", - }, - ChannelType: { - DM: "dm", - GroupDM: "group_dm", - GuildText: "guild_text", - }, - MessageType: { - Default: "default", - ChatInputCommand: "chat_command", - ContextMenuCommand: "context_command", - }, - GatewayIntentBits: {}, - Partials: {}, + ...actual, + resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"), + updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), + resolveSessionKey: vi.fn(), }; }); -const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - -async function waitForClient() { - const discord = (await import("discord.js")) as unknown as { - __getLastClient: () => { emit: (...args: unknown[]) => void } | null; - }; - for (let i = 0; i < 10; i += 1) { - const client = discord.__getLastClient(); - if (client) return client; - await flush(); - } - return null; -} - beforeEach(() => { - config = { - messages: { responsePrefix: "PFX" }, - discord: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } }, - routing: { allowFrom: [] }, - }; sendMock.mockReset().mockResolvedValue(undefined); - replyMock.mockReset(); updateLastRouteMock.mockReset(); - readAllowFromStoreMock.mockReset().mockResolvedValue([]); - upsertPairingRequestMock - .mockReset() - .mockResolvedValue({ code: "PAIRCODE", created: true }); + dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => { + dispatcher.sendFinalReply({ text: "hi" }); + return { queuedFinal: true, counts: { final: 1 } }; + }); + vi.resetModules(); }); -describe("monitorDiscordProvider tool results", () => { - it("sends tool summaries with responsePrefix", async () => { - replyMock.mockImplementation(async (_ctx, opts) => { - await opts?.onToolResult?.({ text: "tool update" }); - return { text: "final reply" }; - }); +describe("discord tool result dispatch", () => { + it("sends status replies with responsePrefix", async () => { + const { createDiscordMessageHandler } = await import("./monitor.js"); + const cfg = { + agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" }, + session: { store: "/tmp/clawdbot-sessions.json" }, + messages: { responsePrefix: "PFX" }, + discord: { dm: { enabled: true, policy: "open" } }, + routing: { allowFrom: [] }, + } as ReturnType; - const controller = new AbortController(); - const run = monitorDiscordProvider({ + const runtimeError = vi.fn(); + const handler = createDiscordMessageHandler({ + cfg, token: "token", - abortSignal: controller.signal, - }); - - const discord = await import("discord.js"); - const client = await waitForClient(); - if (!client) throw new Error("Discord client not created"); - - client.emit(discord.Events.MessageCreate, { - id: "m1", - content: "hello", - author: { id: "u1", bot: false, username: "Ada" }, - channelId: "c1", - channel: { - type: discord.ChannelType.DM, - isSendable: () => false, + runtime: { + log: vi.fn(), + error: runtimeError, + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, }, - guild: undefined, - mentions: { has: () => false }, - attachments: { first: () => undefined }, - type: discord.MessageType.Default, - createdTimestamp: Date.now(), + botUserId: "bot-id", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 10_000, + textLimit: 2000, + replyToMode: "off", + dmEnabled: true, + groupDmEnabled: false, }); - await flush(); - controller.abort(); - await run; + const client = { + fetchChannel: vi.fn().mockResolvedValue({ + type: ChannelType.DM, + name: "dm", + }), + } as unknown as Client; - expect(sendMock).toHaveBeenCalledTimes(2); - expect(sendMock.mock.calls[0][1]).toBe("PFX tool update"); - expect(sendMock.mock.calls[1][1]).toBe("PFX final reply"); - }); + await handler( + { + message: { + id: "m1", + content: "/status", + channelId: "c1", + timestamp: new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [], + mentionedRoles: [], + author: { id: "u1", bot: false, username: "Ada" }, + }, + author: { id: "u1", bot: false, username: "Ada" }, + guild_id: null, + }, + client, + ); + + expect(runtimeError).not.toHaveBeenCalled(); + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock.mock.calls[0]?.[1]).toMatch(/^PFX /); + }, 10000); it("accepts guild messages when mentionPatterns match", async () => { - config = { + const { createDiscordMessageHandler } = await import("./monitor.js"); + const cfg = { + agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" }, + session: { store: "/tmp/clawdbot-sessions.json" }, messages: { responsePrefix: "PFX" }, discord: { - dm: { enabled: true, policy: "open", allowFrom: ["*"] }, + dm: { enabled: true, policy: "open" }, guilds: { "*": { requireMention: true } }, }, routing: { allowFrom: [], groupChat: { mentionPatterns: ["\\bclawd\\b"] }, }, - }; - replyMock.mockResolvedValue({ text: "hi" }); + } as ReturnType; - const controller = new AbortController(); - const run = monitorDiscordProvider({ + const handler = createDiscordMessageHandler({ + cfg, token: "token", - abortSignal: controller.signal, + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: "bot-id", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 10_000, + textLimit: 2000, + replyToMode: "off", + dmEnabled: true, + groupDmEnabled: false, + guildEntries: { "*": { requireMention: true } }, }); - const discord = await import("discord.js"); - const client = await waitForClient(); - if (!client) throw new Error("Discord client not created"); - - client.emit(discord.Events.MessageCreate, { - id: "m2", - content: "clawd: hello", - author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" }, - member: { displayName: "Ada" }, - channelId: "c1", - channel: { - type: discord.ChannelType.GuildText, + const client = { + fetchChannel: vi.fn().mockResolvedValue({ + type: ChannelType.GuildText, name: "general", - isSendable: () => false, + }), + } as unknown as Client; + + await handler( + { + message: { + id: "m2", + content: "clawd: hello", + channelId: "c1", + timestamp: new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [], + mentionedRoles: [], + author: { id: "u1", bot: false, username: "Ada" }, + }, + author: { id: "u1", bot: false, username: "Ada" }, + member: { nickname: "Ada" }, + guild: { id: "g1", name: "Guild" }, + guild_id: "g1", }, - guild: { id: "g1", name: "Guild" }, - mentions: { - has: () => false, - everyone: false, - users: { size: 0 }, - roles: { size: 0 }, - }, - attachments: { first: () => undefined }, - type: discord.MessageType.Default, - createdTimestamp: Date.now(), - }); - - await flush(); - controller.abort(); - await run; - - expect(replyMock).toHaveBeenCalledTimes(1); - expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true); - }); - - it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => { - config = { - ...config, - discord: { dm: { enabled: true, policy: "pairing", allowFrom: [] } }, - }; - - const controller = new AbortController(); - const run = monitorDiscordProvider({ - token: "token", - abortSignal: controller.signal, - }); - - const discord = await import("discord.js"); - const client = await waitForClient(); - if (!client) throw new Error("Discord client not created"); - - const reply = vi.fn().mockResolvedValue(undefined); - client.emit(discord.Events.MessageCreate, { - id: "m3", - content: "hello", - author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" }, - channelId: "c1", - channel: { - type: discord.ChannelType.DM, - isSendable: () => false, - }, - guild: undefined, - mentions: { has: () => false }, - attachments: { first: () => undefined }, - type: discord.MessageType.Default, - createdTimestamp: Date.now(), - reply, - }); - - await flush(); - controller.abort(); - await run; - - expect(replyMock).not.toHaveBeenCalled(); - expect(upsertPairingRequestMock).toHaveBeenCalled(); - expect(reply).toHaveBeenCalledTimes(1); - expect(String(reply.mock.calls[0]?.[0] ?? "")).toContain( - "Pairing code: PAIRCODE", + client, ); - }); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledTimes(1); + }, 10000); }); diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index ed198f5a2..11c8fca36 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -1,25 +1,32 @@ import { - type Attachment, ChannelType, Client, - Events, - GatewayIntentBits, + Command, + type CommandInteraction, + type CommandOptions, type Guild, type Message, - type MessageReaction, - type MessageSnapshot, + MessageCreateListener, + MessageReactionAddListener, + MessageReactionRemoveListener, MessageType, - type PartialMessage, - type PartialMessageReaction, - Partials, - type PartialUser, + type RequestClient, type User, -} from "discord.js"; +} from "@buape/carbon"; +import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway"; +import type { APIAttachment } from "discord-api-types/v10"; +import { ApplicationCommandOptionType, Routes } from "discord-api-types/v10"; + import { chunkMarkdownText, resolveTextChunkLimit, } from "../auto-reply/chunk.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; +import { + buildCommandText, + listNativeCommandSpecs, + shouldHandleTextCommands, +} from "../auto-reply/commands-registry.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js"; import { @@ -28,11 +35,9 @@ import { } from "../auto-reply/reply/mentions.js"; import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; import type { TypingController } from "../auto-reply/reply/typing.js"; +import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; -import type { - DiscordSlashCommandConfig, - ReplyToMode, -} from "../config/config.js"; +import type { ReplyToMode } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; @@ -46,7 +51,9 @@ import { } from "../pairing/pairing-store.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import type { RuntimeEnv } from "../runtime.js"; -import { sendMessageDiscord } from "./send.js"; +import { loadWebMedia } from "../web/media.js"; +import { fetchDiscordApplicationId } from "./probe.js"; +import { reactMessageDiscord, sendMessageDiscord } from "./send.js"; import { normalizeDiscordToken } from "./token.js"; export type MonitorDiscordOpts = { @@ -56,7 +63,6 @@ export type MonitorDiscordOpts = { mediaMaxMb?: number; historyLimit?: number; replyToMode?: ReplyToMode; - slashCommand?: DiscordSlashCommandConfig; }; type DiscordMediaInfo = { @@ -72,6 +78,8 @@ type DiscordHistoryEntry = { messageId?: string; }; +type DiscordReactionEvent = Parameters[0]; + export type DiscordAllowList = { allowAll: boolean; ids: Set; @@ -92,6 +100,15 @@ export type DiscordChannelConfigResolved = { requireMention?: boolean; }; +export type DiscordMessageEvent = Parameters< + MessageCreateListener["handle"] +>[0]; + +export type DiscordMessageHandler = ( + data: DiscordMessageEvent, + client: Client, +) => Promise; + export function resolveDiscordReplyTarget(opts: { replyToMode: ReplyToMode; replyToId?: string; @@ -146,67 +163,218 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const dmConfig = cfg.discord?.dm; const guildEntries = cfg.discord?.guilds; const groupPolicy = cfg.discord?.groupPolicy ?? "open"; - const dmPolicy = dmConfig?.policy ?? "pairing"; const allowFrom = dmConfig?.allowFrom; const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024; const textLimit = resolveTextChunkLimit(cfg, "discord"); - const mentionRegexes = buildMentionRegexes(cfg); - const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); - const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const historyLimit = Math.max( 0, opts.historyLimit ?? cfg.discord?.historyLimit ?? 20, ); const replyToMode = opts.replyToMode ?? cfg.discord?.replyToMode ?? "off"; const dmEnabled = dmConfig?.enabled ?? true; + const dmPolicy = dmConfig?.policy ?? "pairing"; const groupDmEnabled = dmConfig?.groupEnabled ?? false; const groupDmChannels = dmConfig?.groupChannels; + const nativeEnabled = cfg.commands?.native === true; + const nativeDisabledExplicit = cfg.commands?.native === false; + const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const sessionPrefix = "discord:slash"; + const ephemeralDefault = true; if (shouldLogVerbose()) { logVerbose( - `discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))}`, + `discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"}`, ); } - const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.GuildMessageReactions, - GatewayIntentBits.MessageContent, - GatewayIntentBits.DirectMessages, - GatewayIntentBits.DirectMessageReactions, + const applicationId = await fetchDiscordApplicationId(token, 4000); + if (!applicationId) { + throw new Error("Failed to resolve Discord application id"); + } + + const commandSpecs = nativeEnabled ? listNativeCommandSpecs() : []; + const commands = commandSpecs.map((spec) => + createDiscordNativeCommand({ + command: spec, + cfg, + sessionPrefix, + ephemeralDefault, + }), + ); + + const client = new Client( + { + baseUrl: "http://localhost", + deploySecret: "a", + clientId: applicationId, + publicKey: "a", + token, + autoDeploy: nativeEnabled, + }, + { + commands, + listeners: [], + }, + [ + new GatewayPlugin({ + intents: + GatewayIntents.Guilds | + GatewayIntents.GuildMessages | + GatewayIntents.MessageContent | + GatewayIntents.DirectMessages | + GatewayIntents.GuildMessageReactions | + GatewayIntents.DirectMessageReactions, + autoInteractions: true, + }), ], - partials: [ - Partials.Channel, - Partials.Message, - Partials.Reaction, - Partials.User, - ], - }); + ); const logger = getChildLogger({ module: "discord-auto-reply" }); const guildHistories = new Map(); + let botUserId: string | undefined; - client.once(Events.ClientReady, () => { - runtime.log?.(`logged in as ${client.user?.tag ?? "unknown"}`); + if (nativeDisabledExplicit) { + await clearDiscordNativeCommands({ + client, + applicationId, + runtime, + }); + } + + try { + const botUser = await client.fetchUser("@me"); + botUserId = botUser?.id; + } catch (err) { + runtime.error?.( + danger(`discord: failed to fetch bot identity: ${String(err)}`), + ); + } + + const messageHandler = createDiscordMessageHandler({ + cfg, + token, + runtime, + botUserId, + guildHistories, + historyLimit, + mediaMaxBytes, + textLimit, + replyToMode, + dmEnabled, + groupDmEnabled, + groupDmChannels, + allowFrom, + guildEntries, }); - client.on(Events.Error, (err) => { - runtime.error?.(danger(`client error: ${String(err)}`)); - }); + client.listeners.push(new DiscordMessageListener(messageHandler)); + client.listeners.push( + new DiscordReactionListener({ + runtime, + botUserId, + guildEntries, + logger, + }), + ); + client.listeners.push( + new DiscordReactionRemoveListener({ + runtime, + botUserId, + guildEntries, + logger, + }), + ); - client.on(Events.MessageCreate, async (message) => { + runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`); + + await new Promise((resolve) => { + const onAbort = async () => { + try { + const gateway = client.getPlugin("gateway"); + gateway?.disconnect(); + } finally { + resolve(); + } + }; + opts.abortSignal?.addEventListener("abort", () => { + void onAbort(); + }); + }); +} + +async function clearDiscordNativeCommands(params: { + client: Client; + applicationId: string; + runtime: RuntimeEnv; +}) { + try { + await params.client.rest.put( + Routes.applicationCommands(params.applicationId), + { + body: [], + }, + ); + logVerbose("discord: cleared native commands (commands.native=false)"); + } catch (err) { + params.runtime.error?.( + danger(`discord: failed to clear native commands: ${String(err)}`), + ); + } +} + +export function createDiscordMessageHandler(params: { + cfg: ReturnType; + token: string; + runtime: RuntimeEnv; + botUserId?: string; + guildHistories: Map; + historyLimit: number; + mediaMaxBytes: number; + textLimit: number; + replyToMode: ReplyToMode; + dmEnabled: boolean; + groupDmEnabled: boolean; + groupDmChannels?: Array; + allowFrom?: Array; + guildEntries?: Record; +}): DiscordMessageHandler { + const { + cfg, + token, + runtime, + botUserId, + guildHistories, + historyLimit, + mediaMaxBytes, + textLimit, + replyToMode, + dmEnabled, + groupDmEnabled, + groupDmChannels, + allowFrom, + guildEntries, + } = params; + const logger = getChildLogger({ module: "discord-auto-reply" }); + const mentionRegexes = buildMentionRegexes(cfg); + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const groupPolicy = cfg.discord?.groupPolicy ?? "open"; + + return async (data, client) => { try { - if (message.author?.bot) return; - if (!message.author) return; + const message = data.message; + const author = data.author; + if (!author || author.bot) return; + + const isGuildMessage = Boolean(data.guild_id); + const channelInfo = await resolveDiscordChannelInfo( + client, + message.channelId, + ); + const isDirectMessage = channelInfo?.type === ChannelType.DM; + const isGroupDm = channelInfo?.type === ChannelType.GroupDM; - // Discord.js typing excludes GroupDM for message.channel.type; widen for runtime check. - const channelType = message.channel.type as ChannelType; - const isGroupDm = channelType === ChannelType.GroupDM; - const isDirectMessage = channelType === ChannelType.DM; - const isGuildMessage = Boolean(message.guild); if (isGroupDm && !groupDmEnabled) { logVerbose("discord: drop group dm (group dms disabled)"); return; @@ -215,19 +383,80 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { logVerbose("discord: drop dm (dms disabled)"); return; } - if (isDirectMessage && dmPolicy === "disabled") { - logVerbose("discord: drop dm (dmPolicy: disabled)"); - return; + + const dmPolicy = cfg.discord?.dm?.policy ?? "pairing"; + let commandAuthorized = true; + if (isDirectMessage) { + if (dmPolicy === "disabled") { + logVerbose("discord: drop dm (dmPolicy: disabled)"); + return; + } + if (dmPolicy !== "open") { + const storeAllowFrom = await readProviderAllowFromStore( + "discord", + ).catch(() => []); + const effectiveAllowFrom = [...(allowFrom ?? []), ...storeAllowFrom]; + const allowList = normalizeDiscordAllowList(effectiveAllowFrom, [ + "discord:", + "user:", + ]); + const permitted = allowList + ? allowListMatches(allowList, { + id: author.id, + name: author.username, + tag: formatDiscordUserTag(author), + }) + : false; + if (!permitted) { + commandAuthorized = false; + if (dmPolicy === "pairing") { + const { code } = await upsertProviderPairingRequest({ + provider: "discord", + id: author.id, + meta: { + tag: formatDiscordUserTag(author), + name: author.username ?? undefined, + }, + }); + logVerbose( + `discord pairing request sender=${author.id} tag=${formatDiscordUserTag(author)} code=${code}`, + ); + try { + await sendMessageDiscord( + `user:${author.id}`, + [ + "Clawdbot: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "clawdbot pairing approve --provider discord ", + ].join("\n"), + { token, rest: client.rest }, + ); + } catch (err) { + logVerbose( + `discord pairing reply failed for ${author.id}: ${String(err)}`, + ); + } + } else { + logVerbose( + `Blocked unauthorized discord sender ${author.id} (dmPolicy=${dmPolicy})`, + ); + } + return; + } + commandAuthorized = true; + } } - const botId = client.user?.id; - const forwardedSnapshot = resolveForwardedSnapshot(message); - const forwardedText = forwardedSnapshot - ? resolveDiscordSnapshotText(forwardedSnapshot.snapshot) - : ""; - const baseText = resolveDiscordMessageText(message, forwardedText); + const botId = botUserId; + const baseText = resolveDiscordMessageText(message); const wasMentioned = !isDirectMessage && - (Boolean(botId && message.mentions.has(botId)) || + (Boolean( + botId && + message.mentionedUsers?.some((user: User) => user.id === botId), + ) || matchesMentionPatterns(baseText, mentionRegexes)); if (shouldLogVerbose()) { logVerbose( @@ -246,7 +475,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const guildInfo = isGuildMessage ? resolveDiscordGuildEntry({ - guild: message.guild, + guild: data.guild ?? undefined, guildEntries, }) : null; @@ -257,19 +486,26 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { !guildInfo ) { logVerbose( - `Blocked discord guild ${message.guild?.id ?? "unknown"} (not in discord.guilds)`, + `Blocked discord guild ${data.guild_id ?? "unknown"} (not in discord.guilds)`, ); return; } - const channelName = - (isGuildMessage || isGroupDm) && "name" in message.channel - ? message.channel.name - : undefined; + const channelName = channelInfo?.name; const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; const guildSlug = guildInfo?.slug || - (message.guild?.name ? normalizeDiscordSlug(message.guild.name) : ""); + (data.guild?.name ? normalizeDiscordSlug(data.guild.name) : ""); + + const route = resolveAgentRoute({ + cfg, + provider: "discord", + guildId: data.guild_id ?? undefined, + peer: { + kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel", + id: isDirectMessage ? author.id : message.channelId, + }, + }); const channelConfig = isGuildMessage ? resolveDiscordChannelConfig({ guildInfo, @@ -322,12 +558,17 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { return; } - if (isGuildMessage && historyLimit > 0 && baseText) { + const textForHistory = resolveDiscordMessageText(message); + if (isGuildMessage && historyLimit > 0 && textForHistory) { const history = guildHistories.get(message.channelId) ?? []; history.push({ - sender: message.member?.displayName ?? message.author.tag, - body: baseText, - timestamp: message.createdTimestamp, + sender: + data.member?.nickname ?? + author.globalName ?? + author.username ?? + author.id, + body: textForHistory, + timestamp: resolveTimestampMs(message.timestamp), messageId: message.id, }); while (history.length > historyLimit) history.shift(); @@ -338,17 +579,24 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { channelConfig?.requireMention ?? guildInfo?.requireMention ?? true; const hasAnyMention = Boolean( !isDirectMessage && - (message.mentions?.everyone || - (message.mentions?.users?.size ?? 0) > 0 || - (message.mentions?.roles?.size ?? 0) > 0), + (message.mentionedEveryone || + (message.mentionedUsers?.length ?? 0) > 0 || + (message.mentionedRoles?.length ?? 0) > 0), ); - const commandAuthorized = resolveDiscordCommandAuthorized({ - isDirectMessage, - allowFrom, - guildInfo, - author: message.author, + if (!isDirectMessage) { + commandAuthorized = resolveDiscordCommandAuthorized({ + isDirectMessage, + allowFrom, + guildInfo, + author, + }); + } + const allowTextCommands = shouldHandleTextCommands({ + cfg, + surface: "discord", }); const shouldBypassMention = + allowTextCommands && isGuildMessage && resolvedRequireMention && !wasMentioned && @@ -356,8 +604,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { commandAuthorized && hasControlCommand(baseText); const canDetectMention = Boolean(botId) || mentionRegexes.length > 0; - if (isGuildMessage && resolvedRequireMention && canDetectMention) { - if (!wasMentioned && !shouldBypassMention) { + if (isGuildMessage && resolvedRequireMention) { + if (botId && !wasMentioned && !shouldBypassMention) { logVerbose( `discord: drop guild message (mention required, botId=${botId})`, ); @@ -382,86 +630,26 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const userOk = !users || allowListMatches(users, { - id: message.author.id, - name: message.author.username, - tag: message.author.tag, + id: author.id, + name: author.username, + tag: formatDiscordUserTag(author), }); if (!userOk) { logVerbose( - `Blocked discord guild sender ${message.author.id} (not in guild users allowlist)`, + `Blocked discord guild sender ${author.id} (not in guild users allowlist)`, ); return; } } } - if (isDirectMessage && dmPolicy !== "open") { - const storeAllowFrom = await readProviderAllowFromStore( - "discord", - ).catch(() => []); - const effectiveAllowFrom = Array.from( - new Set([...(allowFrom ?? []), ...storeAllowFrom]), - ); - const allowList = normalizeDiscordAllowList(effectiveAllowFrom, [ - "discord:", - "user:", - ]); - const permitted = - allowList != null && - allowListMatches(allowList, { - id: message.author.id, - name: message.author.username, - tag: message.author.tag, - }); - if (!permitted) { - if (dmPolicy === "pairing") { - const { code } = await upsertProviderPairingRequest({ - provider: "discord", - id: message.author.id, - meta: { - username: message.author.username, - tag: message.author.tag, - }, - }); - logVerbose( - `discord pairing request sender=${message.author.id} tag=${message.author.tag} code=${code}`, - ); - try { - await message.reply( - [ - "Clawdbot: access not configured.", - "", - `Pairing code: ${code}`, - "", - "Ask the bot owner to approve with:", - "clawdbot pairing approve --provider discord ", - ].join("\n"), - ); - } catch (err) { - logVerbose( - `discord pairing reply failed for ${message.author.id}: ${String(err)}`, - ); - } - } else { - logVerbose( - `Blocked unauthorized discord sender ${message.author.id} (dmPolicy=${dmPolicy})`, - ); - } - return; - } - } - - const route = resolveAgentRoute({ - cfg, - provider: "discord", - guildId: message.guildId ?? undefined, - peer: { - kind: isDirectMessage ? "dm" : "channel", - id: isDirectMessage ? message.author.id : message.channelId, - }, + const systemLocation = resolveDiscordSystemLocation({ + isDirectMessage, + isGroupDm, + guild: data.guild ?? undefined, + channelName: channelName ?? message.channelId, }); - - const systemText = resolveDiscordSystemEvent(message); + const systemText = resolveDiscordSystemEvent(message, systemLocation); if (systemText) { enqueueSystemEvent(systemText, { sessionKey: route.sessionKey, @@ -474,8 +662,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const text = message.content?.trim() ?? media?.placeholder ?? - message.embeds[0]?.description ?? - (forwardedSnapshot ? "" : ""); + message.embeds?.[0]?.description ?? + ""; if (!text) { logVerbose(`discord: drop message ${message.id} (empty content)`); return; @@ -495,7 +683,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { return false; }; if (shouldAckReaction()) { - message.react(ackReaction).catch((err) => { + reactMessageDiscord(message.channelId, message.id, ackReaction, { + rest: client.rest, + }).catch((err) => { logVerbose( `discord react failed for channel ${message.channelId}: ${String(err)}`, ); @@ -503,17 +693,20 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } const fromLabel = isDirectMessage - ? buildDirectLabel(message) - : buildGuildLabel(message); + ? buildDirectLabel(author) + : buildGuildLabel({ + guild: data.guild ?? undefined, + channelName: channelName ?? message.channelId, + channelId: message.channelId, + }); const groupRoom = isGuildMessage && channelSlug ? `#${channelSlug}` : undefined; const groupSubject = isDirectMessage ? undefined : groupRoom; - const messageText = text; let combinedBody = formatAgentEnvelope({ provider: "Discord", from: fromLabel, - timestamp: message.createdTimestamp, - body: messageText, + timestamp: resolveTimestampMs(message.timestamp), + body: text, }); let shouldClearHistory = false; if (!isDirectMessage) { @@ -534,78 +727,47 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { .join("\n"); combinedBody = `[Chat messages since your last reply - for context]\n${historyText}\n\n[Current message - respond to this]\n${combinedBody}`; } - const name = message.author.tag; - const id = message.author.id; + const name = formatDiscordUserTag(author); + const id = author.id; combinedBody = `${combinedBody}\n[from: ${name} user id:${id}]`; shouldClearHistory = true; } - const replyContext = await resolveReplyContext(message); + const replyContext = resolveReplyContext(message); if (replyContext) { combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`; } - if (forwardedSnapshot) { - const forwarderName = message.author.tag ?? message.author.username; - const forwarder = forwarderName - ? `${forwarderName} id:${message.author.id}` - : message.author.id; - const snapshotText = - resolveDiscordSnapshotText(forwardedSnapshot.snapshot) || - ""; - const forwardMetaParts = [ - forwardedSnapshot.messageId - ? `forwarded message id: ${forwardedSnapshot.messageId}` - : null, - forwardedSnapshot.channelId - ? `channel: ${forwardedSnapshot.channelId}` - : null, - forwardedSnapshot.guildId - ? `guild: ${forwardedSnapshot.guildId}` - : null, - typeof forwardedSnapshot.snapshot.type === "number" - ? `snapshot type: ${forwardedSnapshot.snapshot.type}` - : null, - ].filter((entry): entry is string => Boolean(entry)); - const forwardedBody = forwardMetaParts.length - ? `${snapshotText}\n[${forwardMetaParts.join(" ")}]` - : snapshotText; - const forwardedEnvelope = formatAgentEnvelope({ - provider: "Discord", - from: `Forwarded by ${forwarder}`, - timestamp: - forwardedSnapshot.snapshot.createdTimestamp ?? - message.createdTimestamp ?? - undefined, - body: forwardedBody, - }); - combinedBody = `[Forwarded message]\n${forwardedEnvelope}\n\n${combinedBody}`; - } const ctxPayload = { Body: combinedBody, From: isDirectMessage - ? `discord:${message.author.id}` + ? `discord:${author.id}` : `group:${message.channelId}`, - To: `channel:${message.channelId}`, + To: isDirectMessage + ? `user:${author.id}` + : `channel:${message.channelId}`, SessionKey: route.sessionKey, AccountId: route.accountId, ChatType: isDirectMessage ? "direct" : "group", - SenderName: message.member?.displayName ?? message.author.tag, - SenderId: message.author.id, - SenderUsername: message.author.username, - SenderTag: message.author.tag, + SenderName: + data.member?.nickname ?? author.globalName ?? author.username, + SenderId: author.id, + SenderUsername: author.username, + SenderTag: formatDiscordUserTag(author), GroupSubject: groupSubject, GroupRoom: groupRoom, GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined, Provider: "discord" as const, + Surface: "discord" as const, WasMentioned: wasMentioned, MessageSid: message.id, - Timestamp: message.createdTimestamp, + Timestamp: resolveTimestampMs(message.timestamp), MediaPath: media?.path, MediaType: media?.contentType, MediaUrl: media?.path, CommandAuthorized: commandAuthorized, + CommandSource: "text" as const, }; const replyTarget = ctxPayload.To ?? undefined; if (!replyTarget) { @@ -622,7 +784,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { storePath, sessionKey: route.mainSessionKey, provider: "discord", - to: `user:${message.author.id}`, + to: `user:${author.id}`, accountId: route.accountId, }); } @@ -639,10 +801,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const dispatcher = createReplyDispatcher({ responsePrefix: cfg.messages?.responsePrefix, deliver: async (payload) => { - await deliverReplies({ + await deliverDiscordReply({ replies: [payload], target: replyTarget, token, + rest: client.rest, runtime, replyToMode, textLimit, @@ -700,123 +863,502 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } catch (err) { runtime.error?.(danger(`handler failed: ${String(err)}`)); } - }); + }; +} - const handleReactionEvent = async ( - reaction: MessageReaction | PartialMessageReaction, - user: User | PartialUser, - action: "added" | "removed", - ) => { - try { - if (!user || user.bot) return; - const resolvedReaction = reaction.partial - ? await reaction.fetch() - : reaction; - const message = (resolvedReaction.message as Message | PartialMessage) - .partial - ? await resolvedReaction.message.fetch() - : resolvedReaction.message; - const guild = message.guild; - if (!guild) return; +class DiscordMessageListener extends MessageCreateListener { + constructor(private handler: DiscordMessageHandler) { + super(); + } + + async handle(data: DiscordMessageEvent, client: Client) { + await this.handler(data, client); + } +} + +class DiscordReactionListener extends MessageReactionAddListener { + constructor( + private params: { + runtime: RuntimeEnv; + botUserId?: string; + guildEntries?: Record; + logger: ReturnType; + }, + ) { + super(); + } + + async handle(data: DiscordReactionEvent, client: Client) { + await handleDiscordReactionEvent({ + data, + client, + action: "added", + botUserId: this.params.botUserId, + guildEntries: this.params.guildEntries, + logger: this.params.logger, + }); + } +} + +class DiscordReactionRemoveListener extends MessageReactionRemoveListener { + constructor( + private params: { + runtime: RuntimeEnv; + botUserId?: string; + guildEntries?: Record; + logger: ReturnType; + }, + ) { + super(); + } + + async handle(data: DiscordReactionEvent, client: Client) { + await handleDiscordReactionEvent({ + data, + client, + action: "removed", + botUserId: this.params.botUserId, + guildEntries: this.params.guildEntries, + logger: this.params.logger, + }); + } +} + +async function handleDiscordReactionEvent(params: { + data: DiscordReactionEvent; + client: Client; + action: "added" | "removed"; + botUserId?: string; + guildEntries?: Record; + logger: ReturnType; +}) { + try { + const { data, client, action, botUserId, guildEntries } = params; + if (!("user" in data)) return; + const user = data.user; + if (!user || user.bot) return; + if (!data.guild_id) return; + + const guildInfo = resolveDiscordGuildEntry({ + guild: data.guild ?? undefined, + guildEntries, + }); + if (guildEntries && Object.keys(guildEntries).length > 0 && !guildInfo) { + return; + } + + const channel = await client.fetchChannel(data.channel_id); + if (!channel) return; + const channelName = + "name" in channel ? (channel.name ?? undefined) : undefined; + const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; + const channelConfig = resolveDiscordChannelConfig({ + guildInfo, + channelId: data.channel_id, + channelName, + channelSlug, + }); + if (channelConfig?.allowed === false) return; + + if (botUserId && user.id === botUserId) return; + + const reactionMode = guildInfo?.reactionNotifications ?? "own"; + const message = await data.message.fetch().catch(() => null); + const messageAuthorId = message?.author?.id ?? undefined; + const shouldNotify = shouldEmitDiscordReactionNotification({ + mode: reactionMode, + botId: botUserId, + messageAuthorId, + userId: user.id, + userName: user.username, + userTag: formatDiscordUserTag(user), + allowlist: guildInfo?.users, + }); + if (!shouldNotify) return; + + const emojiLabel = formatDiscordReactionEmoji(data.emoji); + const actorLabel = formatDiscordUserTag(user); + const guildSlug = + guildInfo?.slug || + (data.guild?.name + ? normalizeDiscordSlug(data.guild.name) + : data.guild_id); + const channelLabel = channelSlug + ? `#${channelSlug}` + : channelName + ? `#${normalizeDiscordSlug(channelName)}` + : `#${data.channel_id}`; + const authorLabel = message?.author + ? formatDiscordUserTag(message.author) + : undefined; + const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${data.message_id}`; + const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText; + const cfg = loadConfig(); + const route = resolveAgentRoute({ + cfg, + provider: "discord", + guildId: data.guild_id ?? undefined, + peer: { kind: "channel", id: data.channel_id }, + }); + enqueueSystemEvent(text, { + sessionKey: route.sessionKey, + contextKey: `discord:reaction:${action}:${data.message_id}:${user.id}:${emojiLabel}`, + }); + } catch (err) { + params.logger.error( + danger(`discord reaction handler failed: ${String(err)}`), + ); + } +} + +function createDiscordNativeCommand(params: { + command: { + name: string; + description: string; + acceptsArgs: boolean; + }; + cfg: ReturnType; + sessionPrefix: string; + ephemeralDefault: boolean; +}) { + const { command, cfg, sessionPrefix, ephemeralDefault } = params; + return new (class extends Command { + name = command.name; + description = command.description; + defer = true; + ephemeral = ephemeralDefault; + options = command.acceptsArgs + ? ([ + { + name: "input", + description: "Command input", + type: ApplicationCommandOptionType.String, + required: false, + }, + ] satisfies CommandOptions) + : undefined; + + async run(interaction: CommandInteraction) { + const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const user = interaction.user; + if (!user) return; + const channel = interaction.channel; + const channelType = channel?.type; + const isDirectMessage = channelType === ChannelType.DM; + const isGroupDm = channelType === ChannelType.GroupDM; + const channelName = + channel && "name" in channel ? (channel.name as string) : undefined; + const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; + const prompt = buildCommandText( + this.name, + command.acceptsArgs + ? interaction.options.getString("input") + : undefined, + ); const guildInfo = resolveDiscordGuildEntry({ - guild, - guildEntries, + guild: interaction.guild ?? undefined, + guildEntries: cfg.discord?.guilds, }); - if (guildEntries && Object.keys(guildEntries).length > 0 && !guildInfo) { + if (useAccessGroups && interaction.guild) { + const channelConfig = resolveDiscordChannelConfig({ + guildInfo, + channelId: channel?.id ?? "", + channelName, + channelSlug, + }); + const channelAllowlistConfigured = + Boolean(guildInfo?.channels) && + Object.keys(guildInfo?.channels ?? {}).length > 0; + const channelAllowed = channelConfig?.allowed !== false; + const allowByPolicy = isDiscordGroupAllowedByPolicy({ + groupPolicy: cfg.discord?.groupPolicy ?? "open", + channelAllowlistConfigured, + channelAllowed, + }); + if (!allowByPolicy) { + await interaction.reply({ + content: "This channel is not allowed.", + }); + return; + } + } + const dmEnabled = cfg.discord?.dm?.enabled ?? true; + const dmPolicy = cfg.discord?.dm?.policy ?? "pairing"; + let commandAuthorized = true; + if (isDirectMessage) { + if (!dmEnabled || dmPolicy === "disabled") { + await interaction.reply({ content: "Discord DMs are disabled." }); + return; + } + if (dmPolicy !== "open") { + const storeAllowFrom = await readProviderAllowFromStore( + "discord", + ).catch(() => []); + const effectiveAllowFrom = [ + ...(cfg.discord?.dm?.allowFrom ?? []), + ...storeAllowFrom, + ]; + const allowList = normalizeDiscordAllowList(effectiveAllowFrom, [ + "discord:", + "user:", + ]); + const permitted = allowList + ? allowListMatches(allowList, { + id: user.id, + name: user.username, + tag: formatDiscordUserTag(user), + }) + : false; + if (!permitted) { + commandAuthorized = false; + if (dmPolicy === "pairing") { + const { code } = await upsertProviderPairingRequest({ + provider: "discord", + id: user.id, + meta: { + tag: formatDiscordUserTag(user), + name: user.username ?? undefined, + }, + }); + await interaction.reply({ + content: [ + "Clawdbot: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "clawdbot pairing approve --provider discord ", + ].join("\n"), + ephemeral: true, + }); + } else { + await interaction.reply({ + content: "You are not authorized to use this command.", + ephemeral: true, + }); + } + return; + } + commandAuthorized = true; + } + } + if (guildInfo?.users && !isDirectMessage) { + const allowList = normalizeDiscordAllowList(guildInfo.users, [ + "discord:", + "user:", + ]); + if ( + allowList && + !allowListMatches(allowList, { + id: user.id, + name: user.username, + tag: formatDiscordUserTag(user), + }) + ) { + await interaction.reply({ + content: "You are not authorized to use this command.", + }); + return; + } + } + if (isGroupDm && cfg.discord?.dm?.groupEnabled === false) { + await interaction.reply({ content: "Discord group DMs are disabled." }); return; } - const channelName = - "name" in message.channel - ? (message.channel.name ?? undefined) - : undefined; - const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; - const channelConfig = resolveDiscordChannelConfig({ - guildInfo, - channelId: message.channelId, - channelName, - channelSlug, - }); - if (channelConfig?.allowed === false) return; - const botId = client.user?.id; - if (botId && user.id === botId) return; - - const reactionMode = guildInfo?.reactionNotifications ?? "own"; - const shouldNotify = shouldEmitDiscordReactionNotification({ - mode: reactionMode, - botId, - messageAuthorId: message.author?.id, - userId: user.id, - userName: user.username, - userTag: user.tag, - allowlist: guildInfo?.users, - }); - if (!shouldNotify) return; - - const emojiLabel = formatDiscordReactionEmoji(resolvedReaction); - const actorLabel = user.tag ?? user.username ?? user.id; - const guildSlug = - guildInfo?.slug || - (guild.name ? normalizeDiscordSlug(guild.name) : guild.id); - const channelLabel = channelSlug - ? `#${channelSlug}` - : channelName - ? `#${normalizeDiscordSlug(channelName)}` - : `#${message.channelId}`; - const authorLabel = message.author?.tag ?? message.author?.username; - const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${message.id}`; - const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText; + const isGuild = Boolean(interaction.guild); + const channelId = channel?.id ?? "unknown"; + const interactionId = interaction.rawData.id; const route = resolveAgentRoute({ cfg, provider: "discord", - guildId: guild.id, - peer: { kind: "channel", id: message.channelId }, + guildId: interaction.guild?.id ?? undefined, + peer: { + kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel", + id: isDirectMessage ? user.id : channelId, + }, }); - enqueueSystemEvent(text, { - sessionKey: route.sessionKey, - contextKey: `discord:reaction:${action}:${message.id}:${user.id}:${emojiLabel}`, + const ctxPayload = { + Body: prompt, + From: isDirectMessage ? `discord:${user.id}` : `group:${channelId}`, + To: `slash:${user.id}`, + SessionKey: `agent:${route.agentId}:${sessionPrefix}:${user.id}`, + AccountId: route.accountId, + ChatType: isDirectMessage ? "direct" : "group", + GroupSubject: isGuild ? interaction.guild?.name : undefined, + SenderName: user.globalName ?? user.username, + SenderId: user.id, + SenderUsername: user.username, + SenderTag: formatDiscordUserTag(user), + Provider: "discord" as const, + Surface: "discord" as const, + WasMentioned: true, + MessageSid: interactionId, + Timestamp: Date.now(), + CommandAuthorized: commandAuthorized, + CommandSource: "native" as const, + }; + + let didReply = false; + const dispatcher = createReplyDispatcher({ + responsePrefix: cfg.messages?.responsePrefix, + deliver: async (payload, _info) => { + await deliverDiscordInteractionReply({ + interaction, + payload, + textLimit: resolveTextChunkLimit(cfg, "discord"), + preferFollowUp: didReply, + }); + didReply = true; + }, + onError: (err) => { + console.error(err); + }, }); - } catch (err) { - runtime.error?.( - danger(`discord reaction handler failed: ${String(err)}`), - ); + + const replyResult = await getReplyFromConfig(ctxPayload, undefined, cfg); + const replies = replyResult + ? Array.isArray(replyResult) + ? replyResult + : [replyResult] + : []; + for (const reply of replies) { + dispatcher.sendFinalReply(reply); + } + await dispatcher.waitForIdle(); } + })(); +} + +async function deliverDiscordInteractionReply(params: { + interaction: CommandInteraction; + payload: ReplyPayload; + textLimit: number; + preferFollowUp: boolean; +}) { + const { interaction, payload, textLimit, preferFollowUp } = params; + const mediaList = + payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const text = payload.text ?? ""; + + const sendMessage = async ( + content: string, + files?: { name: string; data: Buffer }[], + ) => { + const payload = + files && files.length > 0 + ? { + content, + files: files.map((file) => { + if (file.data instanceof Blob) { + return { name: file.name, data: file.data }; + } + const arrayBuffer = Uint8Array.from(file.data).buffer; + return { name: file.name, data: new Blob([arrayBuffer]) }; + }), + } + : { content }; + if (!preferFollowUp) { + await interaction.reply(payload); + return; + } + await interaction.followUp(payload); }; - client.on(Events.MessageReactionAdd, async (reaction, user) => { - await handleReactionEvent(reaction, user, "added"); - }); + if (mediaList.length > 0) { + const media = await Promise.all( + mediaList.map(async (url) => { + const loaded = await loadWebMedia(url); + return { + name: loaded.fileName ?? "upload", + data: loaded.buffer, + }; + }), + ); + const caption = text.length > textLimit ? text.slice(0, textLimit) : text; + await sendMessage(caption, media); + if (text.length > textLimit) { + const remaining = text.slice(textLimit).trim(); + if (remaining) { + for (const chunk of chunkMarkdownText(remaining, textLimit)) { + await interaction.followUp({ content: chunk }); + } + } + } + return; + } - client.on(Events.MessageReactionRemove, async (reaction, user) => { - await handleReactionEvent(reaction, user, "removed"); - }); + if (!text.trim()) return; + for (const chunk of chunkMarkdownText(text, textLimit)) { + await sendMessage(chunk); + } +} - await client.login(token); +async function deliverDiscordReply(params: { + replies: ReplyPayload[]; + target: string; + token: string; + rest?: RequestClient; + runtime: RuntimeEnv; + textLimit: number; + replyToMode: ReplyToMode; +}) { + const chunkLimit = Math.min(params.textLimit, 2000); + for (const payload of params.replies) { + const mediaList = + payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const text = payload.text ?? ""; + if (!text && mediaList.length === 0) continue; - await new Promise((resolve, reject) => { - const onAbort = () => { - cleanup(); - void client.destroy(); - resolve(); - }; - const onError = (err: Error) => { - cleanup(); - reject(err); - }; - const cleanup = () => { - opts.abortSignal?.removeEventListener("abort", onAbort); - client.off(Events.Error, onError); - }; - opts.abortSignal?.addEventListener("abort", onAbort, { once: true }); - client.on(Events.Error, onError); - }); + if (mediaList.length === 0) { + for (const chunk of chunkMarkdownText(text, chunkLimit)) { + const trimmed = chunk.trim(); + if (!trimmed) continue; + await sendMessageDiscord(params.target, trimmed, { + token: params.token, + rest: params.rest, + }); + } + continue; + } + + const firstMedia = mediaList[0]; + if (!firstMedia) continue; + await sendMessageDiscord(params.target, text, { + token: params.token, + rest: params.rest, + mediaUrl: firstMedia, + }); + for (const extra of mediaList.slice(1)) { + await sendMessageDiscord(params.target, "", { + token: params.token, + rest: params.rest, + mediaUrl: extra, + }); + } + } +} + +async function resolveDiscordChannelInfo( + client: Client, + channelId: string, +): Promise<{ type: ChannelType; name?: string } | null> { + try { + const channel = await client.fetchChannel(channelId); + if (!channel) return null; + const name = "name" in channel ? (channel.name ?? undefined) : undefined; + return { type: channel.type, name }; + } catch (err) { + logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`); + return null; + } } async function resolveMedia( message: Message, maxBytes: number, ): Promise { - const attachment = message.attachments.first(); + const attachment = message.attachments?.[0]; if (!attachment) return null; const res = await fetch(attachment.url); if (!res.ok) { @@ -827,8 +1369,8 @@ async function resolveMedia( const buffer = Buffer.from(await res.arrayBuffer()); const mime = await detectMime({ buffer, - headerMime: attachment.contentType ?? res.headers.get("content-type"), - filePath: attachment.name ?? attachment.url, + headerMime: attachment.content_type ?? res.headers.get("content-type"), + filePath: attachment.filename ?? attachment.url, }); const saved = await saveMediaBuffer(buffer, mime, "inbound", maxBytes); return { @@ -838,8 +1380,8 @@ async function resolveMedia( }; } -function inferPlaceholder(attachment: Attachment): string { - const mime = attachment.contentType ?? ""; +function inferPlaceholder(attachment: APIAttachment): string { + const mime = attachment.content_type ?? ""; if (mime.startsWith("image/")) return ""; if (mime.startsWith("video/")) return ""; if (mime.startsWith("audio/")) return ""; @@ -850,389 +1392,300 @@ function resolveDiscordMessageText( message: Message, fallbackText?: string, ): string { - const attachment = message.attachments.first(); + const attachment = message.attachments?.[0]; return ( message.content?.trim() || (attachment ? inferPlaceholder(attachment) : "") || - message.embeds[0]?.description || + message.embeds?.[0]?.description || fallbackText?.trim() || "" ); } -function resolveDiscordSnapshotText(snapshot: MessageSnapshot): string { - return snapshot.content?.trim() || snapshot.embeds[0]?.description || ""; +function resolveReplyContext(message: Message): string | null { + const referenced = message.referencedMessage; + if (!referenced?.author) return null; + const referencedText = resolveDiscordMessageText(referenced); + if (!referencedText) return null; + const fromLabel = referenced.author + ? buildDirectLabel(referenced.author) + : "Unknown"; + const body = `${referencedText}\n[discord message id: ${referenced.id} channel: ${referenced.channelId} from: ${formatDiscordUserTag(referenced.author)} user id:${referenced.author?.id ?? "unknown"}]`; + return formatAgentEnvelope({ + provider: "Discord", + from: fromLabel, + timestamp: resolveTimestampMs(referenced.timestamp), + body, + }); } -async function resolveReplyContext(message: Message): Promise { - if (!message.reference?.messageId) return null; - try { - const referenced = await message.fetchReference(); - if (!referenced?.author) return null; - const referencedText = resolveDiscordMessageText(referenced); - if (!referencedText) return null; - const channelType = referenced.channel.type as ChannelType; - const isDirectMessage = channelType === ChannelType.DM; - const fromLabel = isDirectMessage - ? buildDirectLabel(referenced) - : (referenced.member?.displayName ?? referenced.author.tag); - const body = `${referencedText}\n[discord message id: ${referenced.id} channel: ${referenced.channelId} from: ${referenced.author.tag} user id:${referenced.author.id}]`; - return formatAgentEnvelope({ - provider: "Discord", - from: fromLabel, - timestamp: referenced.createdTimestamp, - body, - }); - } catch (err) { - logVerbose( - `discord: failed to fetch reply context for ${message.id}: ${String(err)}`, - ); - return null; - } +function buildDirectLabel(author: User) { + const username = formatDiscordUserTag(author); + return `${username} user id:${author.id}`; } -function buildDirectLabel(message: Message) { - const username = message.author.tag; - return `${username} user id:${message.author.id}`; +function buildGuildLabel(params: { + guild?: Guild; + channelName: string; + channelId: string; +}) { + const { guild, channelName, channelId } = params; + return `${guild?.name ?? "Guild"} #${channelName} channel id:${channelId}`; } -function buildGuildLabel(message: Message) { - const channelName = - "name" in message.channel ? message.channel.name : message.channelId; - return `${message.guild?.name ?? "Guild"} #${channelName} channel id:${message.channelId}`; -} - -function resolveDiscordSystemEvent(message: Message): string | null { +function resolveDiscordSystemEvent( + message: Message, + location: string, +): string | null { switch (message.type) { case MessageType.ChannelPinnedMessage: - return buildDiscordSystemEvent(message, "pinned a message"); + return buildDiscordSystemEvent(message, location, "pinned a message"); case MessageType.RecipientAdd: - return buildDiscordSystemEvent(message, "added a recipient"); + return buildDiscordSystemEvent(message, location, "added a recipient"); case MessageType.RecipientRemove: - return buildDiscordSystemEvent(message, "removed a recipient"); + return buildDiscordSystemEvent(message, location, "removed a recipient"); case MessageType.UserJoin: - return buildDiscordSystemEvent(message, "user joined"); + return buildDiscordSystemEvent(message, location, "user joined"); case MessageType.GuildBoost: - return buildDiscordSystemEvent(message, "boosted the server"); + return buildDiscordSystemEvent(message, location, "boosted the server"); case MessageType.GuildBoostTier1: return buildDiscordSystemEvent( message, + location, "boosted the server (Tier 1 reached)", ); case MessageType.GuildBoostTier2: return buildDiscordSystemEvent( message, + location, "boosted the server (Tier 2 reached)", ); case MessageType.GuildBoostTier3: return buildDiscordSystemEvent( message, + location, "boosted the server (Tier 3 reached)", ); case MessageType.ThreadCreated: - return buildDiscordSystemEvent(message, "created a thread"); + return buildDiscordSystemEvent(message, location, "created a thread"); case MessageType.AutoModerationAction: - return buildDiscordSystemEvent(message, "auto moderation action"); + return buildDiscordSystemEvent( + message, + location, + "auto moderation action", + ); case MessageType.GuildIncidentAlertModeEnabled: - return buildDiscordSystemEvent(message, "raid protection enabled"); + return buildDiscordSystemEvent( + message, + location, + "raid protection enabled", + ); case MessageType.GuildIncidentAlertModeDisabled: - return buildDiscordSystemEvent(message, "raid protection disabled"); + return buildDiscordSystemEvent( + message, + location, + "raid protection disabled", + ); case MessageType.GuildIncidentReportRaid: - return buildDiscordSystemEvent(message, "raid reported"); + return buildDiscordSystemEvent(message, location, "raid reported"); case MessageType.GuildIncidentReportFalseAlarm: - return buildDiscordSystemEvent(message, "raid report marked false alarm"); + return buildDiscordSystemEvent( + message, + location, + "raid report marked false alarm", + ); case MessageType.StageStart: - return buildDiscordSystemEvent(message, "stage started"); + return buildDiscordSystemEvent(message, location, "stage started"); case MessageType.StageEnd: - return buildDiscordSystemEvent(message, "stage ended"); + return buildDiscordSystemEvent(message, location, "stage ended"); case MessageType.StageSpeaker: - return buildDiscordSystemEvent(message, "stage speaker updated"); + return buildDiscordSystemEvent( + message, + location, + "stage speaker updated", + ); case MessageType.StageTopic: - return buildDiscordSystemEvent(message, "stage topic updated"); + return buildDiscordSystemEvent(message, location, "stage topic updated"); case MessageType.PollResult: - return buildDiscordSystemEvent(message, "poll results posted"); + return buildDiscordSystemEvent(message, location, "poll results posted"); case MessageType.PurchaseNotification: - return buildDiscordSystemEvent(message, "purchase notification"); + return buildDiscordSystemEvent( + message, + location, + "purchase notification", + ); default: return null; } } -function resolveForwardedSnapshot(message: Message): { - snapshot: MessageSnapshot; - messageId?: string; - channelId?: string; - guildId?: string; -} | null { - const snapshots = message.messageSnapshots; - if (!snapshots || snapshots.size === 0) return null; - const snapshot = snapshots.first(); - if (!snapshot) return null; - const reference = message.reference; - return { - snapshot, - messageId: reference?.messageId ?? undefined, - channelId: reference?.channelId ?? undefined, - guildId: reference?.guildId ?? undefined, - }; -} - -function buildDiscordSystemEvent(message: Message, action: string) { - const channelName = - "name" in message.channel ? message.channel.name : message.channelId; - const channelType = message.channel.type as ChannelType; - const location = message.guild?.name - ? `${message.guild.name} #${channelName}` - : channelType === ChannelType.GroupDM - ? `Group DM #${channelName}` - : "DM"; - const authorLabel = message.author?.tag ?? message.author?.username; +function buildDiscordSystemEvent( + message: Message, + location: string, + action: string, +) { + const authorLabel = message.author + ? formatDiscordUserTag(message.author) + : ""; const actor = authorLabel ? `${authorLabel} ` : ""; return `Discord system: ${actor}${action} in ${location}`; } -function formatDiscordReactionEmoji( - reaction: MessageReaction | PartialMessageReaction, -) { - if (typeof reaction.emoji.toString === "function") { - const rendered = reaction.emoji.toString(); - if (rendered && rendered !== "[object Object]") return rendered; +function resolveDiscordSystemLocation(params: { + isDirectMessage: boolean; + isGroupDm: boolean; + guild?: Guild; + channelName: string; +}) { + const { isDirectMessage, isGroupDm, guild, channelName } = params; + if (isDirectMessage) return "DM"; + if (isGroupDm) return `Group DM #${channelName}`; + return guild?.name ? `${guild.name} #${channelName}` : `#${channelName}`; +} + +function formatDiscordReactionEmoji(emoji: { + id?: string | null; + name?: string | null; +}) { + if (emoji.id && emoji.name) { + return `${emoji.name}:${emoji.id}`; } - if (reaction.emoji.id && reaction.emoji.name) { - return `${reaction.emoji.name}:${reaction.emoji.id}`; + return emoji.name ?? "emoji"; +} + +function formatDiscordUserTag(user: User) { + const discriminator = (user.discriminator ?? "").trim(); + if (discriminator && discriminator !== "0") { + return `${user.username}#${discriminator}`; } - return reaction.emoji.name ?? "emoji"; + return user.username ?? user.id; +} + +function resolveTimestampMs(timestamp?: string | null) { + if (!timestamp) return undefined; + const parsed = Date.parse(timestamp); + return Number.isNaN(parsed) ? undefined : parsed; } export function normalizeDiscordAllowList( raw: Array | undefined, prefixes: string[], -): DiscordAllowList | null { +) { if (!raw || raw.length === 0) return null; const ids = new Set(); const names = new Set(); - let allowAll = false; - - for (const rawEntry of raw) { - let entry = String(rawEntry).trim(); - if (!entry) continue; - if (entry === "*") { - allowAll = true; + const allowAll = raw.some((entry) => String(entry).trim() === "*"); + for (const entry of raw) { + const text = String(entry).trim(); + if (!text || text === "*") continue; + const normalized = normalizeDiscordSlug(text); + const maybeId = text.replace(/^<@!?/, "").replace(/>$/, ""); + if (/^\d+$/.test(maybeId)) { + ids.add(maybeId); continue; } - for (const prefix of prefixes) { - if (entry.toLowerCase().startsWith(prefix)) { - entry = entry.slice(prefix.length); - break; - } - } - const mentionMatch = entry.match(/^<[@#][!]?(\d+)>$/); - if (mentionMatch?.[1]) { - ids.add(mentionMatch[1]); + const prefix = prefixes.find((entry) => text.startsWith(entry)); + if (prefix) { + const candidate = text.slice(prefix.length); + if (candidate) ids.add(candidate); continue; } - entry = entry.trim(); - if (entry.startsWith("@") || entry.startsWith("#")) { - entry = entry.slice(1); + if (normalized) { + names.add(normalized); } - if (/^\d+$/.test(entry)) { - ids.add(entry); - continue; - } - const normalized = normalizeDiscordName(entry); - if (normalized) names.add(normalized); - const slugged = normalizeDiscordSlug(entry); - if (slugged) names.add(slugged); } - - if (!allowAll && ids.size === 0 && names.size === 0) return null; - return { allowAll, ids, names }; + return { allowAll, ids, names } satisfies DiscordAllowList; } -function normalizeDiscordName(value?: string | null) { - if (!value) return ""; - return value.trim().toLowerCase(); -} - -export function normalizeDiscordSlug(value?: string | null) { - if (!value) return ""; - let text = value.trim().toLowerCase(); - if (!text) return ""; - text = text.replace(/^[@#]+/, ""); - text = text.replace(/[\s_]+/g, "-"); - text = text.replace(/[^a-z0-9-]+/g, "-"); - text = text.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, ""); - return text; +export function normalizeDiscordSlug(value: string) { + return value + .trim() + .toLowerCase() + .replace(/^#/, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); } export function allowListMatches( - allowList: DiscordAllowList, - candidates: { - id?: string; - name?: string | null; - tag?: string | null; - }, + list: DiscordAllowList, + candidate: { id?: string; name?: string; tag?: string }, ) { - if (allowList.allowAll) return true; - const { id, name, tag } = candidates; - if (id && allowList.ids.has(id)) return true; - const normalizedName = normalizeDiscordName(name); - if (normalizedName && allowList.names.has(normalizedName)) return true; - const normalizedTag = normalizeDiscordName(tag); - if (normalizedTag && allowList.names.has(normalizedTag)) return true; - const slugName = normalizeDiscordSlug(name); - if (slugName && allowList.names.has(slugName)) return true; - const slugTag = normalizeDiscordSlug(tag); - if (slugTag && allowList.names.has(slugTag)) return true; + if (list.allowAll) return true; + if (candidate.id && list.ids.has(candidate.id)) return true; + const slug = candidate.name ? normalizeDiscordSlug(candidate.name) : ""; + if (slug && list.names.has(slug)) return true; + if (candidate.tag && list.names.has(normalizeDiscordSlug(candidate.tag))) + return true; return false; } -function resolveDiscordCommandAuthorized(params: { +export function resolveDiscordCommandAuthorized(params: { isDirectMessage: boolean; allowFrom?: Array; guildInfo?: DiscordGuildEntryResolved | null; author: User; -}): boolean { - const { isDirectMessage, allowFrom, guildInfo, author } = params; - if (isDirectMessage) { - if (!Array.isArray(allowFrom) || allowFrom.length === 0) return true; - const allowList = normalizeDiscordAllowList(allowFrom, [ - "discord:", - "user:", - ]); - if (!allowList) return true; - return allowListMatches(allowList, { - id: author.id, - name: author.username, - tag: author.tag, - }); - } - const users = guildInfo?.users; - if (!Array.isArray(users) || users.length === 0) return true; - const allowList = normalizeDiscordAllowList(users, ["discord:", "user:"]); +}) { + if (!params.isDirectMessage) return true; + const allowList = normalizeDiscordAllowList(params.allowFrom, [ + "discord:", + "user:", + ]); if (!allowList) return true; return allowListMatches(allowList, { - id: author.id, - name: author.username, - tag: author.tag, + id: params.author.id, + name: params.author.username, + tag: formatDiscordUserTag(params.author), }); } -export function shouldEmitDiscordReactionNotification(params: { - mode: "off" | "own" | "all" | "allowlist" | undefined; - botId?: string | null; - messageAuthorId?: string | null; - userId: string; - userName?: string | null; - userTag?: string | null; - allowlist?: Array | null; -}) { - const { mode, botId, messageAuthorId, userId, userName, userTag, allowlist } = - params; - const effectiveMode = mode ?? "own"; - if (effectiveMode === "off") return false; - if (effectiveMode === "own") { - if (!botId || !messageAuthorId) return false; - return messageAuthorId === botId; - } - if (effectiveMode === "allowlist") { - if (!Array.isArray(allowlist) || allowlist.length === 0) return false; - const users = normalizeDiscordAllowList(allowlist, ["discord:", "user:"]); - if (!users) return false; - return allowListMatches(users, { - id: userId, - name: userName ?? undefined, - tag: userTag ?? undefined, - }); - } - return true; -} - export function resolveDiscordGuildEntry(params: { - guild: Guild | null; - guildEntries: Record | undefined; + guild?: Guild | Guild | null; + guildEntries?: Record; }): DiscordGuildEntryResolved | null { - const { guild, guildEntries } = params; - if (!guild || !guildEntries || Object.keys(guildEntries).length === 0) { - return null; - } - const guildId = guild.id; - const guildSlug = normalizeDiscordSlug(guild.name); - const direct = guildEntries[guildId]; - if (direct) { - return { - id: guildId, - slug: direct.slug ?? guildSlug, - requireMention: direct.requireMention, - reactionNotifications: direct.reactionNotifications, - users: direct.users, - channels: direct.channels, - }; - } - if (guildSlug && guildEntries[guildSlug]) { - const entry = guildEntries[guildSlug]; - return { - id: guildId, - slug: entry.slug ?? guildSlug, - requireMention: entry.requireMention, - reactionNotifications: entry.reactionNotifications, - users: entry.users, - channels: entry.channels, - }; - } - const matchBySlug = Object.entries(guildEntries).find(([, entry]) => { - const entrySlug = normalizeDiscordSlug(entry.slug); - return entrySlug && entrySlug === guildSlug; - }); - if (matchBySlug) { - const entry = matchBySlug[1]; - return { - id: guildId, - slug: entry.slug ?? guildSlug, - requireMention: entry.requireMention, - reactionNotifications: entry.reactionNotifications, - users: entry.users, - channels: entry.channels, - }; - } - const wildcard = guildEntries["*"]; - if (wildcard) { - return { - id: guildId, - slug: wildcard.slug ?? guildSlug, - requireMention: wildcard.requireMention, - reactionNotifications: wildcard.reactionNotifications, - users: wildcard.users, - channels: wildcard.channels, - }; - } + const guild = params.guild; + const entries = params.guildEntries; + if (!guild || !entries) return null; + const byId = entries[guild.id]; + if (byId) return { ...byId, id: guild.id }; + const slug = normalizeDiscordSlug(guild.name ?? ""); + const bySlug = entries[slug]; + if (bySlug) return { ...bySlug, id: guild.id, slug: slug || bySlug.slug }; + const wildcard = entries["*"]; + if (wildcard) + return { ...wildcard, id: guild.id, slug: slug || wildcard.slug }; return null; } export function resolveDiscordChannelConfig(params: { - guildInfo: DiscordGuildEntryResolved | null; + guildInfo?: DiscordGuildEntryResolved | null; channelId: string; channelName?: string; - channelSlug?: string; + channelSlug: string; }): DiscordChannelConfigResolved | null { const { guildInfo, channelId, channelName, channelSlug } = params; - const channelEntries = guildInfo?.channels; - if (channelEntries && Object.keys(channelEntries).length > 0) { - const entry = - channelEntries[channelId] ?? - (channelSlug - ? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`]) - : undefined) ?? - (channelName - ? channelEntries[normalizeDiscordSlug(channelName)] - : undefined); - if (!entry) return { allowed: false }; + const channels = guildInfo?.channels; + if (!channels) return null; + const byId = channels[channelId]; + if (byId) + return { + allowed: byId.allow !== false, + requireMention: byId.requireMention, + }; + if (channelSlug && channels[channelSlug]) { + const entry = channels[channelSlug]; return { allowed: entry.allow !== false, requireMention: entry.requireMention, }; } - return { allowed: true }; + if (channelName && channels[channelName]) { + const entry = channels[channelName]; + return { + allowed: entry.allow !== false, + requireMention: entry.requireMention, + }; + } + return { allowed: false }; } export function isDiscordGroupAllowedByPolicy(params: { @@ -1248,90 +1701,70 @@ export function isDiscordGroupAllowedByPolicy(params: { } export function resolveGroupDmAllow(params: { - channels: Array | undefined; + channels?: Array; channelId: string; channelName?: string; - channelSlug?: string; + channelSlug: string; }) { const { channels, channelId, channelName, channelSlug } = params; if (!channels || channels.length === 0) return true; - const allowList = normalizeDiscordAllowList(channels, ["channel:"]); - if (!allowList) return true; - return allowListMatches(allowList, { - id: channelId, - name: channelSlug || channelName, - }); + const allowList = channels.map((entry) => + normalizeDiscordSlug(String(entry)), + ); + const candidates = [ + normalizeDiscordSlug(channelId), + channelSlug, + channelName ? normalizeDiscordSlug(channelName) : "", + ].filter(Boolean); + return ( + allowList.includes("*") || + candidates.some((candidate) => allowList.includes(candidate)) + ); } -async function sendTyping(message: Message) { - try { - const channel = message.channel; - if (channel.isSendable()) { - await channel.sendTyping(); - } - } catch { - /* ignore */ - } -} - -async function deliverReplies({ - replies, - target, - token, - runtime, - replyToMode, - textLimit, -}: { - replies: ReplyPayload[]; - target: string; - token: string; - runtime: RuntimeEnv; - replyToMode: ReplyToMode; - textLimit: number; +export function shouldEmitDiscordReactionNotification(params: { + mode?: "off" | "own" | "all" | "allowlist"; + botId?: string; + messageAuthorId?: string; + userId: string; + userName?: string; + userTag?: string; + allowlist?: Array; }) { - let hasReplied = false; - const chunkLimit = Math.min(textLimit, 2000); - for (const payload of replies) { - const mediaList = - payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = payload.text ?? ""; - const replyToId = payload.replyToId; - if (!text && mediaList.length === 0) continue; - if (mediaList.length === 0) { - for (const chunk of chunkMarkdownText(text, chunkLimit)) { - const replyTo = resolveDiscordReplyTarget({ - replyToMode, - replyToId, - hasReplied, - }); - await sendMessageDiscord(target, chunk, { - token, - replyTo, - }); - if (replyTo && !hasReplied) { - hasReplied = true; - } - } - } else { - let first = true; - for (const mediaUrl of mediaList) { - const caption = first ? text : ""; - first = false; - const replyTo = resolveDiscordReplyTarget({ - replyToMode, - replyToId, - hasReplied, - }); - await sendMessageDiscord(target, caption, { - token, - mediaUrl, - replyTo, - }); - if (replyTo && !hasReplied) { - hasReplied = true; - } - } + const mode = params.mode ?? "own"; + if (mode === "off") return false; + if (mode === "all") return true; + if (mode === "own") { + return Boolean(params.botId && params.messageAuthorId === params.botId); + } + if (mode === "allowlist") { + const list = normalizeDiscordAllowList(params.allowlist, [ + "discord:", + "user:", + ]); + if (!list) return false; + return allowListMatches(list, { + id: params.userId, + name: params.userName, + tag: params.userTag, + }); + } + return false; +} + +async function sendTyping(params: { client: Client; channelId: string }) { + try { + const channel = await params.client.fetchChannel(params.channelId); + if (!channel) return; + if ( + "triggerTyping" in channel && + typeof channel.triggerTyping === "function" + ) { + await channel.triggerTyping(); } - runtime.log?.(`delivered reply to ${target}`); + } catch (err) { + logVerbose( + `discord typing cue failed for channel ${params.channelId}: ${String(err)}`, + ); } } diff --git a/src/discord/probe.ts b/src/discord/probe.ts index 074d6b59e..523169f32 100644 --- a/src/discord/probe.ts +++ b/src/discord/probe.ts @@ -74,3 +74,27 @@ export async function probeDiscord( }; } } + +export async function fetchDiscordApplicationId( + token: string, + timeoutMs: number, + fetcher: typeof fetch = fetch, +): Promise { + const normalized = normalizeDiscordToken(token); + if (!normalized) return undefined; + try { + const res = await fetchWithTimeout( + `${DISCORD_API_BASE}/oauth2/applications/@me`, + timeoutMs, + fetcher, + { + Authorization: `Bot ${normalized}`, + }, + ); + if (!res.ok) return undefined; + const json = (await res.json()) as { id?: string }; + return json.id ?? undefined; + } catch { + return undefined; + } +} diff --git a/src/discord/send.test.ts b/src/discord/send.test.ts index f0b291698..62b3a17f5 100644 --- a/src/discord/send.test.ts +++ b/src/discord/send.test.ts @@ -1,4 +1,4 @@ -import { PermissionsBitField, Routes } from "discord.js"; +import { PermissionFlagsBits, Routes } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -53,7 +53,7 @@ const makeRest = () => { get: getMock, patch: patchMock, delete: deleteMock, - } as unknown as import("discord.js").REST, + } as unknown as import("@buape/carbon").RequestClient, postMock, putMock, getMock, @@ -108,9 +108,7 @@ describe("sendMessageDiscord", () => { it("adds missing permission hints on 50013", async () => { const { rest, postMock, getMock } = makeRest(); - const perms = new PermissionsBitField([ - PermissionsBitField.Flags.ViewChannel, - ]); + const perms = PermissionFlagsBits.ViewChannel; const apiError = Object.assign(new Error("Missing Permissions"), { code: 50013, status: 403, @@ -126,7 +124,7 @@ describe("sendMessageDiscord", () => { .mockResolvedValueOnce({ id: "bot1" }) .mockResolvedValueOnce({ id: "guild1", - roles: [{ id: "guild1", permissions: perms.bitfield.toString() }], + roles: [{ id: "guild1", permissions: perms.toString() }], }) .mockResolvedValueOnce({ roles: [] }); @@ -152,7 +150,9 @@ describe("sendMessageDiscord", () => { expect(postMock).toHaveBeenCalledWith( Routes.channelMessages("789"), expect.objectContaining({ - files: [expect.objectContaining({ name: "photo.jpg" })], + body: expect.objectContaining({ + files: [expect.objectContaining({ name: "photo.jpg" })], + }), }), ); }); @@ -268,10 +268,8 @@ describe("fetchChannelPermissionsDiscord", () => { it("calculates permissions from guild roles", async () => { const { rest, getMock } = makeRest(); - const perms = new PermissionsBitField([ - PermissionsBitField.Flags.ViewChannel, - PermissionsBitField.Flags.SendMessages, - ]); + const perms = + PermissionFlagsBits.ViewChannel | PermissionFlagsBits.SendMessages; getMock .mockResolvedValueOnce({ id: "chan1", @@ -282,7 +280,7 @@ describe("fetchChannelPermissionsDiscord", () => { .mockResolvedValueOnce({ id: "guild1", roles: [ - { id: "guild1", permissions: perms.bitfield.toString() }, + { id: "guild1", permissions: perms.toString() }, { id: "role2", permissions: "0" }, ], }) @@ -303,7 +301,7 @@ describe("readMessagesDiscord", () => { vi.clearAllMocks(); }); - it("passes query params as URLSearchParams", async () => { + it("passes query params as an object", async () => { const { rest, getMock } = makeRest(); getMock.mockResolvedValue([]); await readMessagesDiscord( @@ -312,8 +310,8 @@ describe("readMessagesDiscord", () => { { rest, token: "t" }, ); const call = getMock.mock.calls[0]; - const options = call?.[1] as { query?: URLSearchParams }; - expect(options.query?.toString()).toBe("limit=5&before=10"); + const options = call?.[1] as Record; + expect(options).toEqual({ limit: 5, before: "10" }); }); }); @@ -376,8 +374,7 @@ describe("searchMessagesDiscord", () => { { rest, token: "t" }, ); const call = getMock.mock.calls[0]; - const options = call?.[1] as { query?: URLSearchParams }; - expect(options.query?.toString()).toBe("content=hello&limit=5"); + expect(call?.[0]).toBe("/guilds/g1/messages/search?content=hello&limit=5"); }); it("supports channel/author arrays and clamps limit", async () => { @@ -394,9 +391,8 @@ describe("searchMessagesDiscord", () => { { rest, token: "t" }, ); const call = getMock.mock.calls[0]; - const options = call?.[1] as { query?: URLSearchParams }; - expect(options.query?.toString()).toBe( - "content=hello&channel_id=c1&channel_id=c2&author_id=u1&limit=25", + expect(call?.[0]).toBe( + "/guilds/g1/messages/search?content=hello&channel_id=c1&channel_id=c2&author_id=u1&limit=25", ); }); }); @@ -546,13 +542,13 @@ describe("uploadStickerDiscord", () => { name: "clawdbot_wave", description: "Clawdbot waving", tags: "👋", + files: [ + expect.objectContaining({ + name: "asset.png", + contentType: "image/png", + }), + ], }, - files: [ - expect.objectContaining({ - name: "asset.png", - contentType: "image/png", - }), - ], }), ); }); diff --git a/src/discord/send.ts b/src/discord/send.ts index ea446394e..fe4f60f92 100644 --- a/src/discord/send.ts +++ b/src/discord/send.ts @@ -1,4 +1,4 @@ -import { ChannelType, PermissionsBitField, REST, Routes } from "discord.js"; +import { RequestClient } from "@buape/carbon"; import { PollLayoutType } from "discord-api-types/payloads/v10"; import type { RESTAPIPoll } from "discord-api-types/rest/v10"; import type { @@ -11,6 +11,11 @@ import type { APIVoiceState, RESTPostAPIGuildScheduledEventJSONBody, } from "discord-api-types/v10"; +import { + ChannelType, + PermissionFlagsBits, + Routes, +} from "discord-api-types/v10"; import { chunkMarkdownText } from "../auto-reply/chunk.js"; import { loadConfig } from "../config/config.js"; @@ -47,6 +52,10 @@ export class DiscordSendError extends Error { } } +const PERMISSION_ENTRIES = Object.entries(PermissionFlagsBits).filter( + ([, value]) => typeof value === "bigint", +) as Array<[string, bigint]>; + type DiscordRecipient = | { kind: "user"; @@ -61,7 +70,7 @@ type DiscordSendOpts = { token?: string; mediaUrl?: string; verbose?: boolean; - rest?: REST; + rest?: RequestClient; replyTo?: string; }; @@ -72,7 +81,7 @@ export type DiscordSendResult = { export type DiscordReactOpts = { token?: string; - rest?: REST; + rest?: RequestClient; }; export type DiscordReactionUser = { @@ -174,6 +183,10 @@ function resolveToken(explicit?: string) { return token; } +function resolveRest(token: string, rest?: RequestClient) { + return rest ?? new RequestClient(token); +} + function normalizeReactionEmoji(raw: string) { const trimmed = raw.trim(); if (!trimmed) { @@ -252,6 +265,22 @@ function normalizeDiscordPollInput(input: PollInput): RESTAPIPoll { }; } +function addPermissionBits(base: bigint, add?: string) { + if (!add) return base; + return base | BigInt(add); +} + +function removePermissionBits(base: bigint, deny?: string) { + if (!deny) return base; + return base & ~BigInt(deny); +} + +function bitfieldToPermissions(bitfield: bigint) { + return PERMISSION_ENTRIES.filter(([, value]) => (bitfield & value) === value) + .map(([name]) => name) + .sort(); +} + function getDiscordErrorCode(err: unknown) { if (!err || typeof err !== "object") return undefined; const candidate = @@ -279,7 +308,7 @@ async function buildDiscordSendError( err: unknown, ctx: { channelId: string; - rest: REST; + rest: RequestClient; token: string; hasMedia: boolean; }, @@ -327,7 +356,7 @@ async function buildDiscordSendError( } async function resolveChannelId( - rest: REST, + rest: RequestClient, recipient: DiscordRecipient, ): Promise<{ channelId: string; dm?: boolean }> { if (recipient.kind === "channel") { @@ -343,7 +372,7 @@ async function resolveChannelId( } async function sendDiscordText( - rest: REST, + rest: RequestClient, channelId: string, text: string, replyTo?: string, @@ -379,7 +408,7 @@ async function sendDiscordText( } async function sendDiscordMedia( - rest: REST, + rest: RequestClient, channelId: string, text: string, mediaUrl: string, @@ -395,13 +424,13 @@ async function sendDiscordMedia( body: { content: caption || undefined, message_reference: messageReference, + files: [ + { + data: media.buffer, + name: media.fileName ?? "upload", + }, + ], }, - files: [ - { - data: media.buffer, - name: media.fileName ?? "upload", - }, - ], })) as { id: string; channel_id: string }; if (text.length > DISCORD_TEXT_LIMIT) { const remaining = text.slice(DISCORD_TEXT_LIMIT).trim(); @@ -429,7 +458,7 @@ function formatReactionEmoji(emoji: { return buildReactionIdentifier(emoji); } -async function fetchBotUserId(rest: REST) { +async function fetchBotUserId(rest: RequestClient) { const me = (await rest.get(Routes.user("@me"))) as { id?: string }; if (!me?.id) { throw new Error("Failed to resolve bot user id"); @@ -443,7 +472,7 @@ export async function sendMessageDiscord( opts: DiscordSendOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const recipient = parseRecipient(to); const { channelId } = await resolveChannelId(rest, recipient); let result: @@ -482,7 +511,7 @@ export async function sendStickerDiscord( opts: DiscordSendOpts & { content?: string } = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const recipient = parseRecipient(to); const { channelId } = await resolveChannelId(rest, recipient); const content = opts.content?.trim(); @@ -505,7 +534,7 @@ export async function sendPollDiscord( opts: DiscordSendOpts & { content?: string } = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const recipient = parseRecipient(to); const { channelId } = await resolveChannelId(rest, recipient); const content = opts.content?.trim(); @@ -529,7 +558,7 @@ export async function reactMessageDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const encoded = normalizeReactionEmoji(emoji); await rest.put( Routes.channelMessageOwnReaction(channelId, messageId, encoded), @@ -543,7 +572,7 @@ export async function fetchReactionsDiscord( opts: DiscordReactOpts & { limit?: number } = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const message = (await rest.get( Routes.channelMessage(channelId, messageId), )) as { @@ -566,7 +595,7 @@ export async function fetchReactionsDiscord( const encoded = encodeURIComponent(identifier); const users = (await rest.get( Routes.channelMessageReaction(channelId, messageId, encoded), - { query: new URLSearchParams({ limit: String(limit) }) }, + { limit }, )) as Array<{ id: string; username?: string; discriminator?: string }>; summaries.push({ emoji: { @@ -593,7 +622,7 @@ export async function fetchChannelPermissionsDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const channel = (await rest.get(Routes.channel(channelId))) as APIChannel; const channelType = "type" in channel ? channel.type : undefined; const guildId = "guild_id" in channel ? channel.guild_id : undefined; @@ -616,47 +645,47 @@ export async function fetchChannelPermissionsDiscord( const rolesById = new Map( (guild.roles ?? []).map((role) => [role.id, role]), ); - const base = new PermissionsBitField(); const everyoneRole = rolesById.get(guildId); + let base = 0n; if (everyoneRole?.permissions) { - base.add(BigInt(everyoneRole.permissions)); + base = addPermissionBits(base, everyoneRole.permissions); } for (const roleId of member.roles ?? []) { const role = rolesById.get(roleId); if (role?.permissions) { - base.add(BigInt(role.permissions)); + base = addPermissionBits(base, role.permissions); } } - const permissions = new PermissionsBitField(base); + let permissions = base; const overwrites = "permission_overwrites" in channel ? (channel.permission_overwrites ?? []) : []; for (const overwrite of overwrites) { if (overwrite.id === guildId) { - permissions.remove(BigInt(overwrite.deny ?? "0")); - permissions.add(BigInt(overwrite.allow ?? "0")); + permissions = removePermissionBits(permissions, overwrite.deny ?? "0"); + permissions = addPermissionBits(permissions, overwrite.allow ?? "0"); } } for (const overwrite of overwrites) { if (member.roles?.includes(overwrite.id)) { - permissions.remove(BigInt(overwrite.deny ?? "0")); - permissions.add(BigInt(overwrite.allow ?? "0")); + permissions = removePermissionBits(permissions, overwrite.deny ?? "0"); + permissions = addPermissionBits(permissions, overwrite.allow ?? "0"); } } for (const overwrite of overwrites) { if (overwrite.id === botId) { - permissions.remove(BigInt(overwrite.deny ?? "0")); - permissions.add(BigInt(overwrite.allow ?? "0")); + permissions = removePermissionBits(permissions, overwrite.deny ?? "0"); + permissions = addPermissionBits(permissions, overwrite.allow ?? "0"); } } return { channelId, guildId, - permissions: permissions.toArray(), - raw: permissions.bitfield.toString(), + permissions: bitfieldToPermissions(permissions), + raw: permissions.toString(), isDm: false, channelType, }; @@ -668,19 +697,20 @@ export async function readMessagesDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const limit = typeof query.limit === "number" && Number.isFinite(query.limit) ? Math.min(Math.max(Math.floor(query.limit), 1), 100) : undefined; - const params = new URLSearchParams(); - if (limit) params.set("limit", String(limit)); - if (query.before) params.set("before", query.before); - if (query.after) params.set("after", query.after); - if (query.around) params.set("around", query.around); - return (await rest.get(Routes.channelMessages(channelId), { - query: params, - })) as APIMessage[]; + const params: Record = {}; + if (limit) params.limit = limit; + if (query.before) params.before = query.before; + if (query.after) params.after = query.after; + if (query.around) params.around = query.around; + return (await rest.get( + Routes.channelMessages(channelId), + params, + )) as APIMessage[]; } export async function editMessageDiscord( @@ -690,7 +720,7 @@ export async function editMessageDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); return (await rest.patch(Routes.channelMessage(channelId, messageId), { body: { content: payload.content }, })) as APIMessage; @@ -702,7 +732,7 @@ export async function deleteMessageDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); await rest.delete(Routes.channelMessage(channelId, messageId)); return { ok: true }; } @@ -713,7 +743,7 @@ export async function pinMessageDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); await rest.put(Routes.channelPin(channelId, messageId)); return { ok: true }; } @@ -724,7 +754,7 @@ export async function unpinMessageDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); await rest.delete(Routes.channelPin(channelId, messageId)); return { ok: true }; } @@ -734,7 +764,7 @@ export async function listPinsDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); return (await rest.get(Routes.channelPins(channelId))) as APIMessage[]; } @@ -744,7 +774,7 @@ export async function createThreadDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const body: Record = { name: payload.name }; if (payload.autoArchiveMinutes) { body.auto_archive_duration = payload.autoArchiveMinutes; @@ -758,17 +788,18 @@ export async function listThreadsDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); if (payload.includeArchived) { if (!payload.channelId) { throw new Error("channelId required to list archived threads"); } - const params = new URLSearchParams(); - if (payload.before) params.set("before", payload.before); - if (payload.limit) params.set("limit", String(payload.limit)); - return await rest.get(Routes.channelThreads(payload.channelId, "public"), { - query: params, - }); + const params: Record = {}; + if (payload.before) params.before = payload.before; + if (payload.limit) params.limit = payload.limit; + return await rest.get( + Routes.channelThreads(payload.channelId, "public"), + params, + ); } return await rest.get(Routes.guildActiveThreads(payload.guildId)); } @@ -778,7 +809,7 @@ export async function searchMessagesDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const params = new URLSearchParams(); params.set("content", query.content); if (query.channelIds?.length) { @@ -795,9 +826,9 @@ export async function searchMessagesDiscord( const limit = Math.min(Math.max(Math.floor(query.limit), 1), 25); params.set("limit", String(limit)); } - return await rest.get(`/guilds/${query.guildId}/messages/search`, { - query: params, - }); + return await rest.get( + `/guilds/${query.guildId}/messages/search?${params.toString()}`, + ); } export async function listGuildEmojisDiscord( @@ -805,7 +836,7 @@ export async function listGuildEmojisDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); return await rest.get(Routes.guildEmojis(guildId)); } @@ -814,7 +845,7 @@ export async function uploadEmojiDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const media = await loadWebMediaRaw( payload.mediaUrl, DISCORD_MAX_EMOJI_BYTES, @@ -844,7 +875,7 @@ export async function uploadStickerDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const media = await loadWebMediaRaw( payload.mediaUrl, DISCORD_MAX_STICKER_BYTES, @@ -866,14 +897,14 @@ export async function uploadStickerDiscord( "Sticker description", ), tags: normalizeEmojiName(payload.tags, "Sticker tags"), + files: [ + { + data: media.buffer, + name: media.fileName ?? "sticker", + contentType, + }, + ], }, - files: [ - { - data: media.buffer, - name: media.fileName ?? "sticker", - contentType, - }, - ], }); } @@ -883,7 +914,7 @@ export async function fetchMemberInfoDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); return (await rest.get( Routes.guildMember(guildId, userId), )) as APIGuildMember; @@ -894,7 +925,7 @@ export async function fetchRoleInfoDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); return (await rest.get(Routes.guildRoles(guildId))) as APIRole[]; } @@ -903,7 +934,7 @@ export async function addRoleDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); await rest.put( Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId), ); @@ -915,7 +946,7 @@ export async function removeRoleDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); await rest.delete( Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId), ); @@ -927,7 +958,7 @@ export async function fetchChannelInfoDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); return (await rest.get(Routes.channel(channelId))) as APIChannel; } @@ -936,7 +967,7 @@ export async function listGuildChannelsDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); return (await rest.get(Routes.guildChannels(guildId))) as APIChannel[]; } @@ -946,7 +977,7 @@ export async function fetchVoiceStatusDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); return (await rest.get( Routes.guildVoiceState(guildId, userId), )) as APIVoiceState; @@ -957,7 +988,7 @@ export async function listScheduledEventsDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); return (await rest.get( Routes.guildScheduledEvents(guildId), )) as APIGuildScheduledEvent[]; @@ -969,7 +1000,7 @@ export async function createScheduledEventDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); return (await rest.post(Routes.guildScheduledEvents(guildId), { body: payload, })) as APIGuildScheduledEvent; @@ -980,7 +1011,7 @@ export async function timeoutMemberDiscord( opts: DiscordReactOpts = {}, ): Promise { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); let until = payload.until; if (!until && payload.durationMinutes) { const ms = payload.durationMinutes * 60 * 1000; @@ -990,7 +1021,9 @@ export async function timeoutMemberDiscord( Routes.guildMember(payload.guildId, payload.userId), { body: { communication_disabled_until: until ?? null }, - reason: payload.reason, + headers: payload.reason + ? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) } + : undefined, }, )) as APIGuildMember; } @@ -1000,9 +1033,11 @@ export async function kickMemberDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); await rest.delete(Routes.guildMember(payload.guildId, payload.userId), { - reason: payload.reason, + headers: payload.reason + ? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) } + : undefined, }); return { ok: true }; } @@ -1012,7 +1047,7 @@ export async function banMemberDiscord( opts: DiscordReactOpts = {}, ) { const token = resolveToken(opts.token); - const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const rest = resolveRest(token, opts.rest); const deleteMessageDays = typeof payload.deleteMessageDays === "number" && Number.isFinite(payload.deleteMessageDays) @@ -1023,7 +1058,9 @@ export async function banMemberDiscord( deleteMessageDays !== undefined ? { delete_message_days: deleteMessageDays } : undefined, - reason: payload.reason, + headers: payload.reason + ? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) } + : undefined, }); return { ok: true }; } diff --git a/src/gateway/server-providers.ts b/src/gateway/server-providers.ts index 84c94e966..645985b83 100644 --- a/src/gateway/server-providers.ts +++ b/src/gateway/server-providers.ts @@ -473,7 +473,6 @@ export function createProviderManager( token: discordToken.trim(), runtime: discordRuntimeEnv, abortSignal: discordAbort.signal, - slashCommand: cfg.discord?.slashCommand, mediaMaxMb: cfg.discord?.mediaMaxMb, historyLimit: cfg.discord?.historyLimit, }) diff --git a/src/providers/google-shared.test.ts b/src/providers/google-shared.test.ts index f9bbffbc2..fef51a316 100644 --- a/src/providers/google-shared.test.ts +++ b/src/providers/google-shared.test.ts @@ -248,7 +248,6 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - // Should merge into a single user message expect(contents).toHaveLength(1); expect(contents[0].role).toBe("user"); expect(contents[0].parts).toHaveLength(2); @@ -333,7 +332,6 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - // Should have 1 user + 1 merged model message expect(contents).toHaveLength(2); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); @@ -394,17 +392,16 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - // Tool result creates a user turn with functionResponse - // The next user message should be merged into it or there should be proper alternation - // Check that we don't have consecutive user messages - for (let i = 1; i < contents.length; i++) { - if (contents[i].role === "user" && contents[i - 1].role === "user") { - // If consecutive, they should have been merged - expect.fail("Consecutive user messages should be merged"); - } - } - // The conversation should be valid for Gemini - expect(contents.length).toBeGreaterThan(0); + expect(contents).toHaveLength(3); + expect(contents[0].role).toBe("user"); + expect(contents[1].role).toBe("model"); + expect(contents[2].role).toBe("user"); + const toolResponsePart = contents[2].parts?.find( + (part) => + typeof part === "object" && part !== null && "functionResponse" in part, + ); + const toolResponse = asRecord(toolResponsePart); + expect(toolResponse.functionResponse).toBeTruthy(); }); it("ensures function call comes after user turn, not after model turn", () => { @@ -472,11 +469,14 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - // Consecutive model messages should be merged so function call is in same turn as text expect(contents).toHaveLength(2); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); - // The model message should have both text and function call - expect(contents[1].parts?.length).toBe(2); + const toolCallPart = contents[1].parts?.find( + (part) => + typeof part === "object" && part !== null && "functionCall" in part, + ); + const toolCall = asRecord(toolCallPart); + expect(toolCall.functionCall).toBeTruthy(); }); }); diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index 26ce25d27..b4fca93b2 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -8,6 +8,11 @@ import { resolveTextChunkLimit, } from "../auto-reply/chunk.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; +import { + buildCommandText, + listNativeCommandSpecs, + shouldHandleTextCommands, +} from "../auto-reply/commands-registry.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js"; import { @@ -389,6 +394,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const channelsConfig = cfg.slack?.channels; const dmEnabled = dmConfig?.enabled ?? true; const groupPolicy = cfg.slack?.groupPolicy ?? "open"; + const useAccessGroups = cfg.commands?.useAccessGroups !== false; const reactionMode = cfg.slack?.reactionNotifications ?? "own"; const reactionAllowlist = cfg.slack?.reactionAllowlist ?? []; const slashCommand = resolveSlackSlashCommandConfig( @@ -672,7 +678,12 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { name: senderName, }); const hasAnyMention = /<@[^>]+>/.test(message.text ?? ""); + const allowTextCommands = shouldHandleTextCommands({ + cfg, + surface: "slack", + }); const shouldBypassMention = + allowTextCommands && isRoom && channelConfig?.requireMention && !wasMentioned && @@ -1301,193 +1312,242 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { }, ); - if (slashCommand.enabled) { + const handleSlashCommand = async (params: { + command: SlackCommandMiddlewareArgs["command"]; + ack: SlackCommandMiddlewareArgs["ack"]; + respond: SlackCommandMiddlewareArgs["respond"]; + prompt: string; + }) => { + const { command, ack, respond, prompt } = params; + try { + if (!prompt.trim()) { + await ack({ + text: "Message required.", + response_type: "ephemeral", + }); + return; + } + await ack(); + + if (botUserId && command.user_id === botUserId) return; + + const channelInfo = await resolveChannelName(command.channel_id); + const channelType = + channelInfo?.type ?? + (command.channel_name === "directmessage" ? "im" : undefined); + const isDirectMessage = channelType === "im"; + const isGroupDm = channelType === "mpim"; + const isRoom = channelType === "channel" || channelType === "group"; + + if (isDirectMessage && !dmEnabled) { + await respond({ + text: "Slack DMs are disabled.", + response_type: "ephemeral", + }); + return; + } + if (isGroupDm && !groupDmEnabled) { + await respond({ + text: "Slack group DMs are disabled.", + response_type: "ephemeral", + }); + return; + } + if (isGroupDm && groupDmChannels.length > 0) { + const allowList = normalizeAllowListLower(groupDmChannels); + const channelName = channelInfo?.name; + const candidates = [ + command.channel_id, + channelName ? `#${channelName}` : undefined, + channelName, + channelName ? normalizeSlackSlug(channelName) : undefined, + ] + .filter((value): value is string => Boolean(value)) + .map((value) => value.toLowerCase()); + const permitted = + allowList.includes("*") || + candidates.some((candidate) => allowList.includes(candidate)); + if (!permitted) { + await respond({ + text: "This group DM is not allowed.", + response_type: "ephemeral", + }); + return; + } + } + + const storeAllowFrom = await readProviderAllowFromStore("slack").catch( + () => [], + ); + const effectiveAllowFrom = normalizeAllowList([ + ...allowFrom, + ...storeAllowFrom, + ]); + const effectiveAllowFromLower = + normalizeAllowListLower(effectiveAllowFrom); + + let commandAuthorized = true; + if (isDirectMessage) { + if (!dmEnabled || dmPolicy === "disabled") { + await respond({ + text: "Slack DMs are disabled.", + response_type: "ephemeral", + }); + return; + } + if (dmPolicy !== "open") { + const sender = await resolveUserName(command.user_id); + const senderName = sender?.name ?? undefined; + const permitted = allowListMatches({ + allowList: effectiveAllowFromLower, + id: command.user_id, + name: senderName, + }); + if (!permitted) { + if (dmPolicy === "pairing") { + const { code } = await upsertProviderPairingRequest({ + provider: "slack", + id: command.user_id, + meta: { name: senderName }, + }); + await respond({ + text: [ + "Clawdbot: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "clawdbot pairing approve --provider slack ", + ].join("\n"), + response_type: "ephemeral", + }); + } else { + await respond({ + text: "You are not authorized to use this command.", + response_type: "ephemeral", + }); + } + return; + } + commandAuthorized = true; + } + } + + if (isRoom) { + const channelConfig = resolveSlackChannelConfig({ + channelId: command.channel_id, + channelName: channelInfo?.name, + channels: channelsConfig, + }); + if ( + useAccessGroups && + !isSlackRoomAllowedByPolicy({ + groupPolicy, + channelAllowlistConfigured: + Boolean(channelsConfig) && + Object.keys(channelsConfig ?? {}).length > 0, + channelAllowed: channelConfig?.allowed !== false, + }) + ) { + await respond({ + text: "This channel is not allowed.", + response_type: "ephemeral", + }); + return; + } + if (useAccessGroups && channelConfig?.allowed === false) { + await respond({ + text: "This channel is not allowed.", + response_type: "ephemeral", + }); + return; + } + } + + const sender = await resolveUserName(command.user_id); + const senderName = sender?.name ?? command.user_name ?? command.user_id; + const channelName = channelInfo?.name; + const roomLabel = channelName + ? `#${channelName}` + : `#${command.channel_id}`; + const isRoomish = isRoom || isGroupDm; + const route = resolveAgentRoute({ + cfg, + provider: "slack", + teamId: teamId || undefined, + peer: { + kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group", + id: isDirectMessage ? command.user_id : command.channel_id, + }, + }); + + const ctxPayload = { + Body: prompt, + From: isDirectMessage + ? `slack:${command.user_id}` + : isRoom + ? `slack:channel:${command.channel_id}` + : `slack:group:${command.channel_id}`, + To: `slash:${command.user_id}`, + ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group", + GroupSubject: isRoomish ? roomLabel : undefined, + SenderName: senderName, + SenderId: command.user_id, + Provider: "slack" as const, + Surface: "slack" as const, + WasMentioned: true, + MessageSid: command.trigger_id, + Timestamp: Date.now(), + SessionKey: `agent:${route.agentId}:${slashCommand.sessionPrefix}:${command.user_id}`, + AccountId: route.accountId, + CommandSource: "native" as const, + CommandAuthorized: commandAuthorized, + }; + + const replyResult = await getReplyFromConfig(ctxPayload, undefined, cfg); + const replies = replyResult + ? Array.isArray(replyResult) + ? replyResult + : [replyResult] + : []; + + await deliverSlackSlashReplies({ + replies, + respond, + ephemeral: slashCommand.ephemeral, + textLimit, + }); + } catch (err) { + runtime.error?.(danger(`slack slash handler failed: ${String(err)}`)); + await respond({ + text: "Sorry, something went wrong handling that command.", + response_type: "ephemeral", + }); + } + }; + + const nativeCommands = + cfg.commands?.native === true ? listNativeCommandSpecs() : []; + if (nativeCommands.length > 0) { + for (const command of nativeCommands) { + app.command( + `/${command.name}`, + async ({ command: cmd, ack, respond }: SlackCommandMiddlewareArgs) => { + const prompt = buildCommandText(command.name, cmd.text); + await handleSlashCommand({ command: cmd, ack, respond, prompt }); + }, + ); + } + } else if (slashCommand.enabled) { app.command( slashCommand.name, async ({ command, ack, respond }: SlackCommandMiddlewareArgs) => { - try { - const prompt = command.text?.trim(); - if (!prompt) { - await ack({ - text: "Message required.", - response_type: "ephemeral", - }); - return; - } - await ack(); - - if (botUserId && command.user_id === botUserId) return; - - const channelInfo = await resolveChannelName(command.channel_id); - const channelType = - channelInfo?.type ?? - (command.channel_name === "directmessage" ? "im" : undefined); - const isDirectMessage = channelType === "im"; - const isGroupDm = channelType === "mpim"; - const isRoom = channelType === "channel" || channelType === "group"; - - if (isDirectMessage && !dmEnabled) { - await respond({ - text: "Slack DMs are disabled.", - response_type: "ephemeral", - }); - return; - } - if (isGroupDm && !groupDmEnabled) { - await respond({ - text: "Slack group DMs are disabled.", - response_type: "ephemeral", - }); - return; - } - if (isGroupDm && groupDmChannels.length > 0) { - const allowList = normalizeAllowListLower(groupDmChannels); - const channelName = channelInfo?.name; - const candidates = [ - command.channel_id, - channelName ? `#${channelName}` : undefined, - channelName, - channelName ? normalizeSlackSlug(channelName) : undefined, - ] - .filter((value): value is string => Boolean(value)) - .map((value) => value.toLowerCase()); - const permitted = - allowList.includes("*") || - candidates.some((candidate) => allowList.includes(candidate)); - if (!permitted) { - await respond({ - text: "This group DM is not allowed.", - response_type: "ephemeral", - }); - return; - } - } - - if (isDirectMessage) { - if (!dmEnabled || dmPolicy === "disabled") { - await respond({ - text: "Slack DMs are disabled.", - response_type: "ephemeral", - }); - return; - } - if (dmPolicy !== "open") { - const storeAllowFrom = await readProviderAllowFromStore( - "slack", - ).catch(() => []); - const effectiveAllowFrom = normalizeAllowList([ - ...allowFrom, - ...storeAllowFrom, - ]); - const sender = await resolveUserName(command.user_id); - const permitted = allowListMatches({ - allowList: normalizeAllowListLower(effectiveAllowFrom), - id: command.user_id, - name: sender?.name ?? undefined, - }); - if (!permitted) { - if (dmPolicy === "pairing") { - const senderName = sender?.name ?? undefined; - const { code } = await upsertProviderPairingRequest({ - provider: "slack", - id: command.user_id, - meta: { name: senderName }, - }); - await respond({ - text: [ - "Clawdbot: access not configured.", - "", - `Pairing code: ${code}`, - "", - "Ask the bot owner to approve with:", - "clawdbot pairing approve --provider slack ", - ].join("\n"), - response_type: "ephemeral", - }); - } else { - await respond({ - text: "You are not authorized to use this command.", - response_type: "ephemeral", - }); - } - return; - } - } - } - - if (isRoom) { - const channelConfig = resolveSlackChannelConfig({ - channelId: command.channel_id, - channelName: channelInfo?.name, - channels: channelsConfig, - }); - if (channelConfig?.allowed === false) { - await respond({ - text: "This channel is not allowed.", - response_type: "ephemeral", - }); - return; - } - } - - const sender = await resolveUserName(command.user_id); - const senderName = - sender?.name ?? command.user_name ?? command.user_id; - const channelName = channelInfo?.name; - const roomLabel = channelName - ? `#${channelName}` - : `#${command.channel_id}`; - const isRoomish = isRoom || isGroupDm; - const route = resolveAgentRoute({ - cfg, - provider: "slack", - teamId: teamId || undefined, - peer: { kind: "dm", id: command.user_id }, - }); - - const ctxPayload = { - Body: prompt, - From: isDirectMessage - ? `slack:${command.user_id}` - : isRoom - ? `slack:channel:${command.channel_id}` - : `slack:group:${command.channel_id}`, - To: `slash:${command.user_id}`, - ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group", - GroupSubject: isRoomish ? roomLabel : undefined, - SenderName: senderName, - Provider: "slack" as const, - WasMentioned: true, - MessageSid: command.trigger_id, - Timestamp: Date.now(), - SessionKey: `agent:${route.agentId}:${slashCommand.sessionPrefix}:${command.user_id}`, - AccountId: route.accountId, - }; - - const replyResult = await getReplyFromConfig( - ctxPayload, - undefined, - cfg, - ); - const replies = replyResult - ? Array.isArray(replyResult) - ? replyResult - : [replyResult] - : []; - - await deliverSlackSlashReplies({ - replies, - respond, - ephemeral: slashCommand.ephemeral, - textLimit, - }); - } catch (err) { - runtime.error?.(danger(`slack slash handler failed: ${String(err)}`)); - await respond({ - text: "Sorry, something went wrong handling that command.", - response_type: "ephemeral", - }); - } + await handleSlashCommand({ + command, + ack, + respond, + prompt: command.text?.trim() ?? "", + }); }, ); } diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 27ba44a1f..e9553508e 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -41,6 +41,7 @@ const onSpy = vi.fn(); const stopSpy = vi.fn(); const sendChatActionSpy = vi.fn(); const setMessageReactionSpy = vi.fn(async () => undefined); +const setMyCommandsSpy = vi.fn(async () => undefined); const sendMessageSpy = vi.fn(async () => ({ message_id: 77 })); const sendAnimationSpy = vi.fn(async () => ({ message_id: 78 })); const sendPhotoSpy = vi.fn(async () => ({ message_id: 79 })); @@ -48,6 +49,7 @@ type ApiStub = { config: { use: (arg: unknown) => void }; sendChatAction: typeof sendChatActionSpy; setMessageReaction: typeof setMessageReactionSpy; + setMyCommands: typeof setMyCommandsSpy; sendMessage: typeof sendMessageSpy; sendAnimation: typeof sendAnimationSpy; sendPhoto: typeof sendPhotoSpy; @@ -56,6 +58,7 @@ const apiStub: ApiStub = { config: { use: useSpy }, sendChatAction: sendChatActionSpy, setMessageReaction: setMessageReactionSpy, + setMyCommands: setMyCommandsSpy, sendMessage: sendMessageSpy, sendAnimation: sendAnimationSpy, sendPhoto: sendPhotoSpy, @@ -95,6 +98,7 @@ describe("createTelegramBot", () => { sendAnimationSpy.mockReset(); sendPhotoSpy.mockReset(); setMessageReactionSpy.mockReset(); + setMyCommandsSpy.mockReset(); }); it("installs grammY throttler", () => { @@ -275,6 +279,16 @@ describe("createTelegramBot", () => { ]); }); + it("clears native commands when disabled", () => { + loadConfig.mockReturnValue({ + commands: { native: false }, + }); + + createTelegramBot({ token: "tok" }); + + expect(setMyCommandsSpy).toHaveBeenCalledWith([]); + }); + it("skips group messages when requireMention is enabled and no mention matches", async () => { onSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType< diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index cbd5289b0..487a1c038 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -9,6 +9,10 @@ import { resolveTextChunkLimit, } from "../auto-reply/chunk.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; +import { + buildCommandText, + listNativeCommandSpecs, +} from "../auto-reply/commands-registry.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js"; import { @@ -160,6 +164,9 @@ export function createTelegramBot(opts: TelegramBotOptions) { ); }; const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "off"; + const nativeEnabled = cfg.commands?.native === true; + const nativeDisabledExplicit = cfg.commands?.native === false; + const useAccessGroups = cfg.commands?.useAccessGroups !== false; const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const mediaMaxBytes = @@ -483,6 +490,139 @@ export function createTelegramBot(opts: TelegramBotOptions) { if (!queuedFinal) return; }; + const nativeCommands = nativeEnabled ? listNativeCommandSpecs() : []; + if (nativeCommands.length > 0) { + bot.api + .setMyCommands( + nativeCommands.map((command) => ({ + command: command.name, + description: command.description, + })), + ) + .catch((err) => { + runtime.error?.( + danger(`telegram setMyCommands failed: ${String(err)}`), + ); + }); + + for (const command of nativeCommands) { + bot.command(command.name, async (ctx) => { + const msg = ctx.message; + if (!msg) return; + const chatId = msg.chat.id; + const isGroup = + msg.chat.type === "group" || msg.chat.type === "supergroup"; + + if (isGroup && useAccessGroups) { + const groupPolicy = cfg.telegram?.groupPolicy ?? "open"; + if (groupPolicy === "disabled") { + await bot.api.sendMessage( + chatId, + "Telegram group commands are disabled.", + ); + return; + } + if (groupPolicy === "allowlist") { + const senderId = msg.from?.id; + if (senderId == null) { + await bot.api.sendMessage( + chatId, + "You are not authorized to use this command.", + ); + return; + } + const senderUsername = msg.from?.username ?? ""; + if ( + !isSenderAllowed({ + allow: groupAllow, + senderId: String(senderId), + senderUsername, + }) + ) { + await bot.api.sendMessage( + chatId, + "You are not authorized to use this command.", + ); + return; + } + } + const groupAllowlist = resolveGroupPolicy(chatId); + if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) { + await bot.api.sendMessage(chatId, "This group is not allowed."); + return; + } + } + + const allowFromList = Array.isArray(allowFrom) + ? allowFrom.map((entry) => String(entry).trim()).filter(Boolean) + : []; + const senderId = msg.from?.id ? String(msg.from.id) : ""; + const senderUsername = msg.from?.username ?? ""; + const commandAuthorized = + allowFromList.length === 0 || + allowFromList.includes("*") || + (senderId && allowFromList.includes(senderId)) || + (senderId && allowFromList.includes(`telegram:${senderId}`)) || + (senderUsername && + allowFromList.some( + (entry) => + entry.toLowerCase() === senderUsername.toLowerCase() || + entry.toLowerCase() === `@${senderUsername.toLowerCase()}`, + )); + if (!commandAuthorized) { + await bot.api.sendMessage( + chatId, + "You are not authorized to use this command.", + ); + return; + } + + const prompt = buildCommandText(command.name, ctx.match ?? ""); + const ctxPayload = { + Body: prompt, + From: isGroup ? `group:${chatId}` : `telegram:${chatId}`, + To: `slash:${senderId || chatId}`, + ChatType: isGroup ? "group" : "direct", + GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined, + SenderName: buildSenderName(msg), + SenderId: senderId || undefined, + SenderUsername: senderUsername || undefined, + Surface: "telegram", + MessageSid: String(msg.message_id), + Timestamp: msg.date ? msg.date * 1000 : undefined, + WasMentioned: true, + CommandAuthorized: commandAuthorized, + CommandSource: "native" as const, + SessionKey: `telegram:slash:${senderId || chatId}`, + }; + + const replyResult = await getReplyFromConfig( + ctxPayload, + undefined, + cfg, + ); + const replies = replyResult + ? Array.isArray(replyResult) + ? replyResult + : [replyResult] + : []; + await deliverReplies({ + replies, + chatId: String(chatId), + token: opts.token, + runtime, + bot, + replyToMode, + textLimit, + }); + }); + } + } else if (nativeDisabledExplicit) { + bot.api.setMyCommands([]).catch((err) => { + runtime.error?.(danger(`telegram clear commands failed: ${String(err)}`)); + }); + } + bot.on("message", async (ctx) => { try { const msg = ctx.message;