mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:18:37 +00:00
feat(commands): unify chat commands (#275)
* Chat commands: registry, access groups, Carbon * Chat commands: clear native commands on disable * fix(commands): align command surface typing * docs(changelog): note commands registry (PR #275) --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
- Telegram: stop typing after tool results. Thanks @AbhisekBasu1 for PR #322.
|
- Telegram: stop typing after tool results. Thanks @AbhisekBasu1 for PR #322.
|
||||||
- Messages: stop defaulting ack reactions to 👀 when identity emoji is missing.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Gateway/CLI: stop forcing localhost URL in remote mode so remote gateway config works. Thanks @oswalpalash for PR #293.
|
||||||
|
|||||||
@@ -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)
|
### `web` (WhatsApp web provider)
|
||||||
|
|
||||||
WhatsApp runs through the gateway’s web provider. It starts automatically when a linked session exists.
|
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
|
moderation: false
|
||||||
},
|
},
|
||||||
replyToMode: "off", // off | first | all
|
replyToMode: "off", // off | first | all
|
||||||
slashCommand: { // user-installed app slash commands
|
|
||||||
enabled: true,
|
|
||||||
name: "clawd",
|
|
||||||
sessionPrefix: "discord:slash",
|
|
||||||
ephemeral: true
|
|
||||||
},
|
|
||||||
dm: {
|
dm: {
|
||||||
enabled: true, // disable all DMs when false
|
enabled: true, // disable all DMs when false
|
||||||
policy: "pairing", // pairing | allowlist | open | disabled
|
policy: "pairing", // pairing | allowlist | open | disabled
|
||||||
|
|||||||
@@ -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"`.
|
- 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`.
|
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.
|
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.
|
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.*`).
|
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.
|
- 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: 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 `#`.
|
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**
|
**Scopes**
|
||||||
- ✅ `bot`
|
- ✅ `bot`
|
||||||
- ✅ `applications.commands` (only if you want slash commands; otherwise leave unchecked)
|
- ✅ `applications.commands` (required for native commands)
|
||||||
|
|
||||||
**Bot Permissions** (minimal baseline)
|
**Bot Permissions** (minimal baseline)
|
||||||
- ✅ View Channels
|
- ✅ View Channels
|
||||||
@@ -179,12 +179,6 @@ Notes:
|
|||||||
moderation: false
|
moderation: false
|
||||||
},
|
},
|
||||||
replyToMode: "off",
|
replyToMode: "off",
|
||||||
slashCommand: {
|
|
||||||
enabled: true,
|
|
||||||
name: "clawd",
|
|
||||||
sessionPrefix: "discord:slash",
|
|
||||||
ephemeral: true
|
|
||||||
},
|
|
||||||
dm: {
|
dm: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
policy: "pairing", // pairing | allowlist | open | disabled
|
policy: "pairing", // pairing | allowlist | open | disabled
|
||||||
@@ -225,7 +219,6 @@ Ack reactions are controlled globally via `messages.ackReaction` +
|
|||||||
- `guilds.<id>.channels`: channel rules (keys are channel slugs or ids).
|
- `guilds.<id>.channels`: channel rules (keys are channel slugs or ids).
|
||||||
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
||||||
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
- `guilds.<id>.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.
|
- `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).
|
- `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).
|
- `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.
|
- Use `*` to allow any sender/channel.
|
||||||
- When `guilds.<id>.channels` is present, channels not listed are denied by default.
|
- When `guilds.<id>.channels` is present, channels not listed are denied by default.
|
||||||
|
|
||||||
Slash command notes:
|
Native command notes:
|
||||||
- Register a chat input command in Discord with at least one string option (e.g., `prompt`).
|
- The registered commands mirror Clawdbot’s chat commands.
|
||||||
- The first non-empty string option is treated as the prompt.
|
- Native commands honor the same allowlists as DMs/guild messages (`discord.dm.allowFrom`, `discord.guilds`, per-channel rules).
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Tool actions
|
## Tool actions
|
||||||
The agent can call `discord` with actions like:
|
The agent can call `discord` with actions like:
|
||||||
|
|||||||
@@ -572,6 +572,8 @@ Slash commands are owner-only (gated by `whatsapp.allowFrom` and command authori
|
|||||||
| `/model <name>` | Switch AI model (see below) |
|
| `/model <name>` | Switch AI model (see below) |
|
||||||
| `/queue instant\|batch\|serial` | Message queuing mode |
|
| `/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?
|
### How do I switch models on the fly?
|
||||||
|
|
||||||
Use `/model` to switch without restarting:
|
Use `/model` to switch without restarting:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Note: `routing.groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/
|
|||||||
## What’s implemented (2025-12-03)
|
## 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).
|
- 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`).
|
- 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:<jid>` 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:<jid>` 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]`.
|
- 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.
|
- 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.
|
- 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 mention`
|
||||||
- `/activation always`
|
- `/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
|
## How to use
|
||||||
1) Add Clawd UK (`+447700900123`) to the group.
|
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.
|
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.
|
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
|
## Testing / verification
|
||||||
- Automated: `pnpm test -- src/web/auto-reply.test.ts --runInBand` (covers mention gating, history injection, sender suffix).
|
- Automated: `pnpm test -- src/web/auto-reply.test.ts --runInBand` (covers mention gating, history injection, sender suffix).
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ Group owners can toggle per-group activation:
|
|||||||
- `/activation mention`
|
- `/activation mention`
|
||||||
- `/activation always`
|
- `/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
|
## Context fields
|
||||||
Group inbound payloads set:
|
Group inbound payloads set:
|
||||||
|
|||||||
@@ -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` — 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 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).
|
- `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`.
|
- Logs: tail `/tmp/clawdbot/clawdbot-*.log` and filter for `web-heartbeat`, `web-reconnect`, `web-auto-reply`, `web-inbound`.
|
||||||
|
|
||||||
## Deep diagnostics
|
## Deep diagnostics
|
||||||
|
|||||||
@@ -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
|
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
|
streaming surfaces can look like duplicates. Prefer `collect`/`steer` if you want
|
||||||
one response per inbound message.
|
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):
|
Defaults (when unset in config):
|
||||||
- All surfaces → `collect`
|
- 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`.
|
Defaults: `debounceMs: 1000`, `cap: 20`, `drop: summarize`.
|
||||||
|
|
||||||
## Per-session overrides
|
## Per-session overrides
|
||||||
- `/queue <mode>` as a standalone command stores the mode for the current session.
|
- Send `/queue <mode>` as a standalone command to store the mode for the current session.
|
||||||
- `/queue <mode>` embedded in a message applies **once** (no persistence).
|
|
||||||
- Options can be combined: `/queue collect debounce:2s cap:25 drop:summarize`
|
- Options can be combined: `/queue collect debounce:2s cap:25 drop:summarize`
|
||||||
- `/queue default` or `/queue reset` clears the session override.
|
- `/queue default` or `/queue reset` clears the session override.
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ Policy-based blocking by provider/chat type (not per session id).
|
|||||||
|
|
||||||
Runtime override (per session entry):
|
Runtime override (per session entry):
|
||||||
- `sendPolicy: "allow" | "deny"` (unset = inherit config)
|
- `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:
|
Enforcement points:
|
||||||
- `chat.send` / `agent` (gateway)
|
- `chat.send` / `agent` (gateway)
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ Runtime override (owner only):
|
|||||||
- `/send on` → allow for this session
|
- `/send on` → allow for this session
|
||||||
- `/send off` → deny for this session
|
- `/send off` → deny for this session
|
||||||
- `/send inherit` → clear override and use config rules
|
- `/send inherit` → clear override and use config rules
|
||||||
|
Send these as standalone messages so they register.
|
||||||
|
|
||||||
## Configuration (optional rename example)
|
## Configuration (optional rename example)
|
||||||
```json5
|
```json5
|
||||||
@@ -76,8 +77,8 @@ Runtime override (owner only):
|
|||||||
- `pnpm clawdbot status` — shows store path and recent sessions.
|
- `pnpm clawdbot status` — shows store path and recent sessions.
|
||||||
- `pnpm clawdbot sessions --json` — dumps every entry (filter with `--active <minutes>`).
|
- `pnpm clawdbot sessions --json` — dumps every entry (filter with `--active <minutes>`).
|
||||||
- `pnpm clawdbot gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access).
|
- `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 `/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) to summarize older context and free up window space.
|
- 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.
|
- JSONL transcripts can be opened directly to review full turns.
|
||||||
|
|
||||||
## Tips
|
## Tips
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ Ack reactions are controlled globally via `messages.ackReaction` +
|
|||||||
- DMs share the `main` session (like WhatsApp/Telegram).
|
- DMs share the `main` session (like WhatsApp/Telegram).
|
||||||
- Channels map to `slack:channel:<channelId>` sessions.
|
- Channels map to `slack:channel:<channelId>` sessions.
|
||||||
- Slash commands use `slack:slash:<userId>` sessions.
|
- Slash commands use `slack:slash:<userId>` 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)
|
## DM security (pairing)
|
||||||
- Default: `slack.dm.policy="pairing"` — unknown DM senders get a pairing code.
|
- Default: `slack.dm.policy="pairing"` — unknown DM senders get a pairing code.
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup
|
|||||||
6) Allowlist + pairing:
|
6) Allowlist + pairing:
|
||||||
- Direct chats: `telegram.allowFrom` (chat ids) or pairing approvals via `clawdbot pairing approve --provider telegram <code>` (alias: `clawdbot telegram pairing approve <code>`).
|
- Direct chats: `telegram.allowFrom` (chat ids) or pairing approvals via `clawdbot pairing approve --provider telegram <code>` (alias: `clawdbot telegram pairing approve <code>`).
|
||||||
- Groups: set `telegram.groupPolicy = "allowlist"` and list senders in `telegram.groupAllowFrom` (fallback: explicit `telegram.allowFrom`).
|
- 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)
|
## Capabilities & limits (Bot API)
|
||||||
- Sees only messages sent after it’s added to a chat; no pre-history access.
|
- Sees only messages sent after it’s added to a chat; no pre-history access.
|
||||||
@@ -71,7 +73,7 @@ Example config:
|
|||||||
## Group etiquette
|
## Group etiquette
|
||||||
- Keep privacy mode off if you expect the bot to read all messages; with privacy on, it only sees commands/mentions.
|
- 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.
|
- 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
|
## Reply tags
|
||||||
To request a threaded reply, the model can include one tag in its output:
|
To request a threaded reply, the model can include one tag in its output:
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
|
|||||||
- Activation modes:
|
- Activation modes:
|
||||||
- `mention` (default): requires @mention or regex match.
|
- `mention` (default): requires @mention or regex match.
|
||||||
- `always`: always triggers.
|
- `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).
|
- Owner = `whatsapp.allowFrom` (or self E.164 if unset).
|
||||||
- **History injection**:
|
- **History injection**:
|
||||||
- Recent messages (default 50) inserted under:
|
- Recent messages (default 50) inserted under:
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.23.0",
|
"packageManager": "pnpm@10.23.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@buape/carbon": "^0.13.0",
|
||||||
"@clack/prompts": "^0.11.0",
|
"@clack/prompts": "^0.11.0",
|
||||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||||
"@homebridge/ciao": "^1.3.4",
|
"@homebridge/ciao": "^1.3.4",
|
||||||
@@ -102,7 +103,6 @@
|
|||||||
"croner": "^9.1.0",
|
"croner": "^9.1.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
"discord-api-types": "^0.38.37",
|
"discord-api-types": "^0.38.37",
|
||||||
"discord.js": "^14.25.1",
|
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"file-type": "^21.3.0",
|
"file-type": "^21.3.0",
|
||||||
|
|||||||
264
pnpm-lock.yaml
generated
264
pnpm-lock.yaml
generated
@@ -22,6 +22,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@buape/carbon':
|
||||||
|
specifier: ^0.13.0
|
||||||
|
version: 0.13.0(@types/react@19.2.7)(hono@4.11.3)
|
||||||
'@clack/prompts':
|
'@clack/prompts':
|
||||||
specifier: ^0.11.0
|
specifier: ^0.11.0
|
||||||
version: 0.11.0
|
version: 0.11.0
|
||||||
@@ -82,9 +85,6 @@ importers:
|
|||||||
discord-api-types:
|
discord-api-types:
|
||||||
specifier: ^0.38.37
|
specifier: ^0.38.37
|
||||||
version: 0.38.37
|
version: 0.38.37
|
||||||
discord.js:
|
|
||||||
specifier: ^14.25.1
|
|
||||||
version: 14.25.1
|
|
||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^17.2.3
|
specifier: ^17.2.3
|
||||||
version: 17.2.3
|
version: 17.2.3
|
||||||
@@ -331,6 +331,9 @@ packages:
|
|||||||
'@borewit/text-codec@0.2.1':
|
'@borewit/text-codec@0.2.1':
|
||||||
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
|
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
|
||||||
|
|
||||||
|
'@buape/carbon@0.13.0':
|
||||||
|
resolution: {integrity: sha512-N52sGIJj832IezL+JmekC4gE7cCORj8r8mCJ1vsHOZiyr3O2pvsUA930E1j+rjStkd67TLxURPRMrpyqAFveIg==}
|
||||||
|
|
||||||
'@cacheable/memory@2.0.7':
|
'@cacheable/memory@2.0.7':
|
||||||
resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==}
|
resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==}
|
||||||
|
|
||||||
@@ -347,6 +350,9 @@ packages:
|
|||||||
'@clack/prompts@0.11.0':
|
'@clack/prompts@0.11.0':
|
||||||
resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==}
|
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':
|
'@crosscopy/clipboard-darwin-arm64@0.2.8':
|
||||||
resolution: {integrity: sha512-Y36ST9k5JZgtDE6SBT45bDNkPKBHd4UEIZgWnC0iC4kAWwdjPmsZ8Mn8e5W0YUKowJ/BDcO+EGm2tVTPQOQKXg==}
|
resolution: {integrity: sha512-Y36ST9k5JZgtDE6SBT45bDNkPKBHd4UEIZgWnC0iC4kAWwdjPmsZ8Mn8e5W0YUKowJ/BDcO+EGm2tVTPQOQKXg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
@@ -398,34 +404,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-0qRWscafAHzQ+DdfXX+YgPN2KDTIzWBNfN5Q6z1CgCWsRxtkwK8HfQUc00xIejfRWSGWPIxcCTg82hvg06bodg==}
|
resolution: {integrity: sha512-0qRWscafAHzQ+DdfXX+YgPN2KDTIzWBNfN5Q6z1CgCWsRxtkwK8HfQUc00xIejfRWSGWPIxcCTg82hvg06bodg==}
|
||||||
engines: {node: '>= 10'}
|
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':
|
'@emnapi/core@1.8.1':
|
||||||
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
|
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
|
||||||
|
|
||||||
@@ -625,6 +603,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-qK6ZgGx0wwOubq/MY6eTbhApQHBUQCvCOsTYpQE01uLvfA2/Prm6egySHlZouKaina1RPuDwfLhCmsRCxwHj3Q==}
|
resolution: {integrity: sha512-qK6ZgGx0wwOubq/MY6eTbhApQHBUQCvCOsTYpQE01uLvfA2/Prm6egySHlZouKaina1RPuDwfLhCmsRCxwHj3Q==}
|
||||||
hasBin: true
|
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':
|
'@img/colour@1.0.0':
|
||||||
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
|
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1152,18 +1136,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
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':
|
'@sinclair/typebox@0.34.46':
|
||||||
resolution: {integrity: sha512-kiW7CtS/NkdvTUjkjUJo7d5JsFfbJ14YjdhDk9KoEgK6nFjKNXZPrX0jfLA8ZlET4cFLHxOZ/0vFKOP+bOxIOQ==}
|
resolution: {integrity: sha512-kiW7CtS/NkdvTUjkjUJo7d5JsFfbJ14YjdhDk9KoEgK6nFjKNXZPrX0jfLA8ZlET4cFLHxOZ/0vFKOP+bOxIOQ==}
|
||||||
|
|
||||||
@@ -1230,6 +1202,9 @@ packages:
|
|||||||
'@types/body-parser@1.19.6':
|
'@types/body-parser@1.19.6':
|
||||||
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
|
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
|
||||||
|
|
||||||
|
'@types/bun@1.2.23':
|
||||||
|
resolution: {integrity: sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A==}
|
||||||
|
|
||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
||||||
@@ -1275,6 +1250,9 @@ packages:
|
|||||||
'@types/node@10.17.60':
|
'@types/node@10.17.60':
|
||||||
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
|
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
|
||||||
|
|
||||||
|
'@types/node@22.19.3':
|
||||||
|
resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==}
|
||||||
|
|
||||||
'@types/node@25.0.3':
|
'@types/node@25.0.3':
|
||||||
resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
|
resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
|
||||||
|
|
||||||
@@ -1290,6 +1268,9 @@ packages:
|
|||||||
'@types/range-parser@1.2.7':
|
'@types/range-parser@1.2.7':
|
||||||
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
||||||
|
|
||||||
|
'@types/react@19.2.7':
|
||||||
|
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
||||||
|
|
||||||
'@types/retry@0.12.0':
|
'@types/retry@0.12.0':
|
||||||
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
|
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
|
||||||
|
|
||||||
@@ -1362,10 +1343,6 @@ packages:
|
|||||||
'@vitest/utils@4.0.16':
|
'@vitest/utils@4.0.16':
|
||||||
resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==}
|
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':
|
'@wasm-audio-decoders/common@9.0.7':
|
||||||
resolution: {integrity: sha512-WRaUuWSKV7pkttBygml/a6dIEpatq2nnZGFIoPTc5yPLkxL6Wk4YaslPM98OPQvWacvNZ+Py9xROGDtrFBDzag==}
|
resolution: {integrity: sha512-WRaUuWSKV7pkttBygml/a6dIEpatq2nnZGFIoPTc5yPLkxL6Wk4YaslPM98OPQvWacvNZ+Py9xROGDtrFBDzag==}
|
||||||
|
|
||||||
@@ -1537,6 +1514,11 @@ packages:
|
|||||||
buffer@6.0.3:
|
buffer@6.0.3:
|
||||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
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:
|
bytes@3.1.2:
|
||||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -1647,6 +1629,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
csstype@3.2.3:
|
||||||
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
curve25519-js@0.0.4:
|
curve25519-js@0.0.4:
|
||||||
resolution: {integrity: sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==}
|
resolution: {integrity: sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==}
|
||||||
|
|
||||||
@@ -1686,13 +1671,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
|
resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
|
||||||
engines: {node: '>=0.3.1'}
|
engines: {node: '>=0.3.1'}
|
||||||
|
|
||||||
|
discord-api-types@0.38.29:
|
||||||
|
resolution: {integrity: sha512-+5BfrjLJN1hrrcK0MxDQli6NSv5lQH7Y3/qaOfk9+k7itex8RkA/UcevVMMLe8B4IKIawr4ITBTb2fBB2vDORg==}
|
||||||
|
|
||||||
discord-api-types@0.38.37:
|
discord-api-types@0.38.37:
|
||||||
resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==}
|
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:
|
docx-preview@0.3.7:
|
||||||
resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==}
|
resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==}
|
||||||
|
|
||||||
@@ -1966,6 +1950,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
||||||
engines: {node: '>=12.0.0'}
|
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:
|
hookified@1.15.0:
|
||||||
resolution: {integrity: sha512-51w+ZZGt7Zw5q7rM3nC4t3aLn/xvKDETsXqMczndvwyVQhAHfUmUuFBRFcos8Iyebtk7OAE9dL26wFNzZVVOkw==}
|
resolution: {integrity: sha512-51w+ZZGt7Zw5q7rM3nC4t3aLn/xvKDETsXqMczndvwyVQhAHfUmUuFBRFcos8Iyebtk7OAE9dL26wFNzZVVOkw==}
|
||||||
|
|
||||||
@@ -2219,9 +2207,6 @@ packages:
|
|||||||
lodash.once@4.1.1:
|
lodash.once@4.1.1:
|
||||||
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||||
|
|
||||||
lodash.snakecase@4.1.1:
|
|
||||||
resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==}
|
|
||||||
|
|
||||||
lodash@4.17.21:
|
lodash@4.17.21:
|
||||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||||
|
|
||||||
@@ -2248,9 +2233,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
magic-bytes.js@1.12.1:
|
|
||||||
resolution: {integrity: sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==}
|
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
@@ -2903,9 +2885,6 @@ packages:
|
|||||||
ts-algebra@2.0.0:
|
ts-algebra@2.0.0:
|
||||||
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
|
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
|
||||||
|
|
||||||
ts-mixer@6.0.4:
|
|
||||||
resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==}
|
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
@@ -2941,13 +2920,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
|
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
undici-types@6.21.0:
|
||||||
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
undici-types@7.16.0:
|
undici-types@7.16.0:
|
||||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||||
|
|
||||||
undici@6.21.3:
|
|
||||||
resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==}
|
|
||||||
engines: {node: '>=18.17'}
|
|
||||||
|
|
||||||
undici@7.18.0:
|
undici@7.18.0:
|
||||||
resolution: {integrity: sha512-CfPufgPFHCYu0W4h1NiKW9+tNJ39o3kWm7Cm29ET1enSJx+AERfz7A2wAr26aY0SZbYzZlTBQtcHy15o60VZfQ==}
|
resolution: {integrity: sha512-CfPufgPFHCYu0W4h1NiKW9+tNJ39o3kWm7Cm29ET1enSJx+AERfz7A2wAr26aY0SZbYzZlTBQtcHy15o60VZfQ==}
|
||||||
engines: {node: '>=20.18.1'}
|
engines: {node: '>=20.18.1'}
|
||||||
@@ -3088,6 +3066,18 @@ packages:
|
|||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
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:
|
ws@8.19.0:
|
||||||
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
|
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
@@ -3197,6 +3187,22 @@ snapshots:
|
|||||||
|
|
||||||
'@borewit/text-codec@0.2.1': {}
|
'@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':
|
'@cacheable/memory@2.0.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@cacheable/utils': 2.3.3
|
'@cacheable/utils': 2.3.3
|
||||||
@@ -3226,6 +3232,9 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
sisteransi: 1.0.5
|
sisteransi: 1.0.5
|
||||||
|
|
||||||
|
'@cloudflare/workers-types@4.20250513.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@crosscopy/clipboard-darwin-arm64@0.2.8':
|
'@crosscopy/clipboard-darwin-arm64@0.2.8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -3261,55 +3270,6 @@ snapshots:
|
|||||||
'@crosscopy/clipboard-win32-arm64-msvc': 0.2.8
|
'@crosscopy/clipboard-win32-arm64-msvc': 0.2.8
|
||||||
'@crosscopy/clipboard-win32-x64-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':
|
'@emnapi/core@1.8.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/wasi-threads': 1.1.0
|
'@emnapi/wasi-threads': 1.1.0
|
||||||
@@ -3440,6 +3400,11 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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/colour@1.0.0': {}
|
||||||
|
|
||||||
'@img/sharp-darwin-arm64@0.34.5':
|
'@img/sharp-darwin-arm64@0.34.5':
|
||||||
@@ -3872,15 +3837,6 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc@4.54.0':
|
'@rollup/rollup-win32-x64-msvc@4.54.0':
|
||||||
optional: true
|
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': {}
|
'@sinclair/typebox@0.34.46': {}
|
||||||
|
|
||||||
'@slack/bolt@4.6.0(@types/express@5.0.6)':
|
'@slack/bolt@4.6.0(@types/express@5.0.6)':
|
||||||
@@ -3997,6 +3953,13 @@ snapshots:
|
|||||||
'@types/connect': 3.4.38
|
'@types/connect': 3.4.38
|
||||||
'@types/node': 25.0.3
|
'@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':
|
'@types/chai@5.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/deep-eql': 4.0.2
|
'@types/deep-eql': 4.0.2
|
||||||
@@ -4047,6 +4010,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/node@10.17.60': {}
|
'@types/node@10.17.60': {}
|
||||||
|
|
||||||
|
'@types/node@22.19.3':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 6.21.0
|
||||||
|
|
||||||
'@types/node@25.0.3':
|
'@types/node@25.0.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.16.0
|
undici-types: 7.16.0
|
||||||
@@ -4061,6 +4028,11 @@ snapshots:
|
|||||||
|
|
||||||
'@types/range-parser@1.2.7': {}
|
'@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.0': {}
|
||||||
|
|
||||||
'@types/retry@0.12.5': {}
|
'@types/retry@0.12.5': {}
|
||||||
@@ -4181,8 +4153,6 @@ snapshots:
|
|||||||
'@vitest/pretty-format': 4.0.16
|
'@vitest/pretty-format': 4.0.16
|
||||||
tinyrainbow: 3.0.3
|
tinyrainbow: 3.0.3
|
||||||
|
|
||||||
'@vladfrangu/async_event_emitter@2.4.7': {}
|
|
||||||
|
|
||||||
'@wasm-audio-decoders/common@9.0.7':
|
'@wasm-audio-decoders/common@9.0.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eshaz/web-worker': 1.2.2
|
'@eshaz/web-worker': 1.2.2
|
||||||
@@ -4377,6 +4347,12 @@ snapshots:
|
|||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
ieee754: 1.2.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: {}
|
bytes@3.1.2: {}
|
||||||
|
|
||||||
cacheable@2.3.1:
|
cacheable@2.3.1:
|
||||||
@@ -4492,6 +4468,9 @@ snapshots:
|
|||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
|
csstype@3.2.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
curve25519-js@0.0.4: {}
|
curve25519-js@0.0.4: {}
|
||||||
|
|
||||||
data-uri-to-buffer@4.0.1: {}
|
data-uri-to-buffer@4.0.1: {}
|
||||||
@@ -4513,26 +4492,9 @@ snapshots:
|
|||||||
|
|
||||||
diff@8.0.2: {}
|
diff@8.0.2: {}
|
||||||
|
|
||||||
discord-api-types@0.38.37: {}
|
discord-api-types@0.38.29: {}
|
||||||
|
|
||||||
discord.js@14.25.1:
|
discord-api-types@0.38.37: {}
|
||||||
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
|
|
||||||
|
|
||||||
docx-preview@0.3.7:
|
docx-preview@0.3.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -4871,6 +4833,9 @@ snapshots:
|
|||||||
|
|
||||||
highlight.js@11.11.1: {}
|
highlight.js@11.11.1: {}
|
||||||
|
|
||||||
|
hono@4.11.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
hookified@1.15.0: {}
|
hookified@1.15.0: {}
|
||||||
|
|
||||||
html-escaper@2.0.2: {}
|
html-escaper@2.0.2: {}
|
||||||
@@ -5112,8 +5077,6 @@ snapshots:
|
|||||||
|
|
||||||
lodash.once@4.1.1: {}
|
lodash.once@4.1.1: {}
|
||||||
|
|
||||||
lodash.snakecase@4.1.1: {}
|
|
||||||
|
|
||||||
lodash@4.17.21: {}
|
lodash@4.17.21: {}
|
||||||
|
|
||||||
long@4.0.0: {}
|
long@4.0.0: {}
|
||||||
@@ -5131,8 +5094,6 @@ snapshots:
|
|||||||
lz-string@1.5.0:
|
lz-string@1.5.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
magic-bytes.js@1.12.1: {}
|
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@@ -5865,8 +5826,6 @@ snapshots:
|
|||||||
|
|
||||||
ts-algebra@2.0.0: {}
|
ts-algebra@2.0.0: {}
|
||||||
|
|
||||||
ts-mixer@6.0.4: {}
|
|
||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
tslog@4.10.2: {}
|
tslog@4.10.2: {}
|
||||||
@@ -5897,9 +5856,9 @@ snapshots:
|
|||||||
|
|
||||||
uint8array-extras@1.5.0: {}
|
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: {}
|
undici@7.18.0: {}
|
||||||
|
|
||||||
@@ -6020,6 +5979,9 @@ snapshots:
|
|||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
|
ws@8.18.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
ws@8.19.0: {}
|
ws@8.19.0: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|||||||
@@ -32,4 +32,11 @@ describe("control command parsing", () => {
|
|||||||
expect(hasControlCommand("/status")).toBe(true);
|
expect(hasControlCommand("/status")).toBe(true);
|
||||||
expect(hasControlCommand("status")).toBe(false);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
const CONTROL_COMMAND_RE =
|
import { listChatCommands } from "./commands-registry.js";
|
||||||
/(?:^|\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",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function hasControlCommand(text?: string): boolean {
|
export function hasControlCommand(text?: string): boolean {
|
||||||
if (!text) return false;
|
if (!text) return false;
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed) return false;
|
if (!trimmed) return false;
|
||||||
const lowered = trimmed.toLowerCase();
|
const lowered = trimmed.toLowerCase();
|
||||||
if (CONTROL_COMMAND_EXACT.has(lowered)) return true;
|
for (const command of listChatCommands()) {
|
||||||
return CONTROL_COMMAND_RE.test(text);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
52
src/auto-reply/commands-registry.test.ts
Normal file
52
src/auto-reply/commands-registry.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
178
src/auto-reply/commands-registry.ts
Normal file
178
src/auto-reply/commands-registry.ts
Normal file
@@ -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<string>;
|
||||||
|
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<string>; regex: RegExp } {
|
||||||
|
if (cachedDetection) return cachedDetection;
|
||||||
|
const exact = new Set<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ export function parseActivationCommand(raw?: string): {
|
|||||||
if (!raw) return { hasCommand: false };
|
if (!raw) return { hasCommand: false };
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return { hasCommand: false };
|
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 };
|
if (!match) return { hasCommand: false };
|
||||||
const mode = normalizeGroupActivation(match[1]);
|
const mode = normalizeGroupActivation(match[1]);
|
||||||
return { hasCommand: true, mode };
|
return { hasCommand: true, mode };
|
||||||
|
|||||||
@@ -512,7 +512,7 @@ describe("directive parsing", () => {
|
|||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
const ctx = {
|
const ctx = {
|
||||||
Body: "please do the thing /verbose on",
|
Body: "please do the thing",
|
||||||
From: "+1004",
|
From: "+1004",
|
||||||
To: "+2000",
|
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(
|
const res = await getReplyFromConfig(
|
||||||
ctx,
|
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) => {
|
await withTempHome(async (home) => {
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
@@ -867,8 +882,8 @@ describe("directive parsing", () => {
|
|||||||
expect(texts).toContain("done");
|
expect(texts).toContain("done");
|
||||||
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
||||||
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
|
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
|
||||||
expect(call?.provider).toBe("openai");
|
expect(call?.provider).toBe("anthropic");
|
||||||
expect(call?.model).toBe("gpt-4.1-mini");
|
expect(call?.model).toBe("claude-opus-4-5");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
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(
|
const res = await getReplyFromConfig(
|
||||||
{
|
{
|
||||||
Body: "please /status now",
|
Body: "please /status now",
|
||||||
@@ -127,8 +134,8 @@ describe("trigger handling", () => {
|
|||||||
makeCfg(home),
|
makeCfg(home),
|
||||||
);
|
);
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("Status");
|
expect(text).not.toContain("Status");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
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) => {
|
await withTempHome(async (home) => {
|
||||||
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
|
payloads: [{ text: "ok" }],
|
||||||
|
meta: {
|
||||||
|
durationMs: 1,
|
||||||
|
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||||
|
},
|
||||||
|
});
|
||||||
const cfg = {
|
const cfg = {
|
||||||
agent: {
|
agent: {
|
||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
@@ -293,8 +307,8 @@ describe("trigger handling", () => {
|
|||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toBe("elevated is not available right now.");
|
expect(text).not.toBe("elevated is not available right now.");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
|
|||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { resolveCommandAuthorization } from "./command-auth.js";
|
import { resolveCommandAuthorization } from "./command-auth.js";
|
||||||
import { hasControlCommand } from "./command-detection.js";
|
import { hasControlCommand } from "./command-detection.js";
|
||||||
|
import { shouldHandleTextCommands } from "./commands-registry.js";
|
||||||
import { getAbortMemory } from "./reply/abort.js";
|
import { getAbortMemory } from "./reply/abort.js";
|
||||||
import { runReplyAgent } from "./reply/agent-runner.js";
|
import { runReplyAgent } from "./reply/agent-runner.js";
|
||||||
import { resolveBlockStreamingChunking } from "./reply/block-streaming.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 { buildCommandContext, handleCommands } from "./reply/commands.js";
|
||||||
import {
|
import {
|
||||||
handleDirectiveOnly,
|
handleDirectiveOnly,
|
||||||
|
type InlineDirectives,
|
||||||
isDirectiveOnly,
|
isDirectiveOnly,
|
||||||
parseInlineDirectives,
|
parseInlineDirectives,
|
||||||
persistInlineDirectives,
|
persistInlineDirectives,
|
||||||
@@ -48,7 +50,7 @@ import {
|
|||||||
defaultGroupActivation,
|
defaultGroupActivation,
|
||||||
resolveGroupRequireMention,
|
resolveGroupRequireMention,
|
||||||
} from "./reply/groups.js";
|
} from "./reply/groups.js";
|
||||||
import { stripMentions } from "./reply/mentions.js";
|
import { stripMentions, stripStructuralPrefixes } from "./reply/mentions.js";
|
||||||
import {
|
import {
|
||||||
createModelSelectionState,
|
createModelSelectionState,
|
||||||
resolveContextTokens,
|
resolveContextTokens,
|
||||||
@@ -83,9 +85,6 @@ export type { GetReplyOptions, ReplyPayload } from "./types.js";
|
|||||||
const BARE_SESSION_RESET_PROMPT =
|
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.";
|
"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) {
|
function normalizeAllowToken(value?: string) {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
return value.trim().toLowerCase();
|
return value.trim().toLowerCase();
|
||||||
@@ -254,7 +253,7 @@ export async function getReplyFromConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const commandAuthorized = ctx.CommandAuthorized ?? true;
|
const commandAuthorized = ctx.CommandAuthorized ?? true;
|
||||||
const commandAuth = resolveCommandAuthorization({
|
resolveCommandAuthorization({
|
||||||
ctx,
|
ctx,
|
||||||
cfg,
|
cfg,
|
||||||
commandAuthorized,
|
commandAuthorized,
|
||||||
@@ -281,7 +280,47 @@ export async function getReplyFromConfig(
|
|||||||
} = sessionState;
|
} = sessionState;
|
||||||
|
|
||||||
const rawBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
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
|
const directives = commandAuthorized
|
||||||
? parsedDirectives
|
? parsedDirectives
|
||||||
: {
|
: {
|
||||||
@@ -468,6 +507,11 @@ export async function getReplyFromConfig(
|
|||||||
triggerBodyNormalized,
|
triggerBodyNormalized,
|
||||||
commandAuthorized,
|
commandAuthorized,
|
||||||
});
|
});
|
||||||
|
const allowTextCommands = shouldHandleTextCommands({
|
||||||
|
cfg,
|
||||||
|
surface: command.surface,
|
||||||
|
commandSource: ctx.CommandSource,
|
||||||
|
});
|
||||||
const isEmptyConfig = Object.keys(cfg).length === 0;
|
const isEmptyConfig = Object.keys(cfg).length === 0;
|
||||||
if (
|
if (
|
||||||
command.isWhatsAppProvider &&
|
command.isWhatsAppProvider &&
|
||||||
@@ -538,20 +582,15 @@ export async function getReplyFromConfig(
|
|||||||
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||||
const rawBodyTrimmed = (ctx.Body ?? "").trim();
|
const rawBodyTrimmed = (ctx.Body ?? "").trim();
|
||||||
const baseBodyTrimmedRaw = baseBody.trim();
|
const baseBodyTrimmedRaw = baseBody.trim();
|
||||||
const strippedCommandBody = isGroup
|
|
||||||
? stripMentions(triggerBodyNormalized, ctx, cfg)
|
|
||||||
: triggerBodyNormalized;
|
|
||||||
if (
|
if (
|
||||||
!commandAuth.isAuthorizedSender &&
|
allowTextCommands &&
|
||||||
CONTROL_COMMAND_PREFIX_RE.test(strippedCommandBody.trim())
|
!commandAuthorized &&
|
||||||
|
!baseBodyTrimmedRaw &&
|
||||||
|
hasControlCommand(rawBody)
|
||||||
) {
|
) {
|
||||||
typing.cleanup();
|
typing.cleanup();
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (!commandAuthorized && !baseBodyTrimmedRaw && hasControlCommand(rawBody)) {
|
|
||||||
typing.cleanup();
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const isBareSessionReset =
|
const isBareSessionReset =
|
||||||
isNewSession &&
|
isNewSession &&
|
||||||
baseBodyTrimmedRaw.length === 0 &&
|
baseBodyTrimmedRaw.length === 0 &&
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { normalizeE164 } from "../../utils.js";
|
|||||||
import { resolveHeartbeatSeconds } from "../../web/reconnect.js";
|
import { resolveHeartbeatSeconds } from "../../web/reconnect.js";
|
||||||
import { getWebAuthAgeMs, webAuthExists } from "../../web/session.js";
|
import { getWebAuthAgeMs, webAuthExists } from "../../web/session.js";
|
||||||
import { resolveCommandAuthorization } from "../command-auth.js";
|
import { resolveCommandAuthorization } from "../command-auth.js";
|
||||||
|
import { shouldHandleTextCommands } from "../commands-registry.js";
|
||||||
import {
|
import {
|
||||||
normalizeGroupActivation,
|
normalizeGroupActivation,
|
||||||
parseActivationCommand,
|
parseActivationCommand,
|
||||||
@@ -47,6 +48,7 @@ import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
|||||||
import { incrementCompactionCount } from "./session-updates.js";
|
import { incrementCompactionCount } from "./session-updates.js";
|
||||||
|
|
||||||
export type CommandContext = {
|
export type CommandContext = {
|
||||||
|
surface: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
isWhatsAppProvider: boolean;
|
isWhatsAppProvider: boolean;
|
||||||
ownerList: string[];
|
ownerList: string[];
|
||||||
@@ -123,7 +125,8 @@ export function buildCommandContext(params: {
|
|||||||
cfg,
|
cfg,
|
||||||
commandAuthorized: params.commandAuthorized,
|
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 =
|
const abortKey =
|
||||||
sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
|
sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
|
||||||
const rawBodyNormalized = triggerBodyNormalized;
|
const rawBodyNormalized = triggerBodyNormalized;
|
||||||
@@ -132,6 +135,7 @@ export function buildCommandContext(params: {
|
|||||||
: rawBodyNormalized;
|
: rawBodyNormalized;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
surface,
|
||||||
provider,
|
provider,
|
||||||
isWhatsAppProvider: auth.isWhatsAppProvider,
|
isWhatsAppProvider: auth.isWhatsAppProvider,
|
||||||
ownerList: auth.ownerList,
|
ownerList: auth.ownerList,
|
||||||
@@ -207,8 +211,13 @@ export async function handleCommands(params: {
|
|||||||
const sendPolicyCommand = parseSendPolicyCommand(
|
const sendPolicyCommand = parseSendPolicyCommand(
|
||||||
command.commandBodyNormalized,
|
command.commandBodyNormalized,
|
||||||
);
|
);
|
||||||
|
const allowTextCommands = shouldHandleTextCommands({
|
||||||
|
cfg,
|
||||||
|
surface: command.surface,
|
||||||
|
commandSource: ctx.CommandSource,
|
||||||
|
});
|
||||||
|
|
||||||
if (activationCommand.hasCommand) {
|
if (allowTextCommands && activationCommand.hasCommand) {
|
||||||
if (!isGroup) {
|
if (!isGroup) {
|
||||||
return {
|
return {
|
||||||
shouldContinue: false,
|
shouldContinue: false,
|
||||||
@@ -255,7 +264,7 @@ export async function handleCommands(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sendPolicyCommand.hasCommand) {
|
if (allowTextCommands && sendPolicyCommand.hasCommand) {
|
||||||
if (!command.isAuthorizedSender) {
|
if (!command.isAuthorizedSender) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`Ignoring /send from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
`Ignoring /send from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||||
@@ -292,10 +301,7 @@ export async function handleCommands(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (allowTextCommands && command.commandBodyNormalized === "/restart") {
|
||||||
command.commandBodyNormalized === "/restart" ||
|
|
||||||
command.commandBodyNormalized.startsWith("/restart ")
|
|
||||||
) {
|
|
||||||
if (!command.isAuthorizedSender) {
|
if (!command.isAuthorizedSender) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`Ignoring /restart from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
`Ignoring /restart from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||||
@@ -311,10 +317,8 @@ export async function handleCommands(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const helpRequested =
|
const helpRequested = command.commandBodyNormalized === "/help";
|
||||||
command.commandBodyNormalized === "/help" ||
|
if (allowTextCommands && helpRequested) {
|
||||||
/(?:^|\s)\/help(?=$|\s|:)\b/i.test(command.commandBodyNormalized);
|
|
||||||
if (helpRequested) {
|
|
||||||
if (!command.isAuthorizedSender) {
|
if (!command.isAuthorizedSender) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`Ignoring /help from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
`Ignoring /help from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||||
@@ -326,9 +330,8 @@ export async function handleCommands(params: {
|
|||||||
|
|
||||||
const statusRequested =
|
const statusRequested =
|
||||||
directives.hasStatusDirective ||
|
directives.hasStatusDirective ||
|
||||||
command.commandBodyNormalized === "/status" ||
|
command.commandBodyNormalized === "/status";
|
||||||
command.commandBodyNormalized.startsWith("/status ");
|
if (allowTextCommands && statusRequested) {
|
||||||
if (statusRequested) {
|
|
||||||
if (!command.isAuthorizedSender) {
|
if (!command.isAuthorizedSender) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`Ignoring /status from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
`Ignoring /status from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||||
@@ -451,7 +454,7 @@ export async function handleCommands(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const abortRequested = isAbortTrigger(command.rawBodyNormalized);
|
const abortRequested = isAbortTrigger(command.rawBodyNormalized);
|
||||||
if (abortRequested) {
|
if (allowTextCommands && abortRequested) {
|
||||||
if (sessionEntry && sessionStore && sessionKey) {
|
if (sessionEntry && sessionStore && sessionKey) {
|
||||||
sessionEntry.abortedLastRun = true;
|
sessionEntry.abortedLastRun = true;
|
||||||
sessionEntry.updatedAt = Date.now();
|
sessionEntry.updatedAt = Date.now();
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function parseSendPolicyCommand(raw?: string): {
|
|||||||
if (!raw) return { hasCommand: false };
|
if (!raw) return { hasCommand: false };
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return { hasCommand: false };
|
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 };
|
if (!match) return { hasCommand: false };
|
||||||
const token = match[1]?.trim().toLowerCase();
|
const token = match[1]?.trim().toLowerCase();
|
||||||
if (!token) return { hasCommand: true };
|
if (!token) return { hasCommand: true };
|
||||||
|
|||||||
@@ -28,8 +28,11 @@ export type MsgContext = {
|
|||||||
SenderE164?: string;
|
SenderE164?: string;
|
||||||
/** Provider label (whatsapp|telegram|discord|imessage|...). */
|
/** Provider label (whatsapp|telegram|discord|imessage|...). */
|
||||||
Provider?: string;
|
Provider?: string;
|
||||||
|
/** Provider surface label (e.g. discord, slack). Prefer this over `Provider` when available. */
|
||||||
|
Surface?: string;
|
||||||
WasMentioned?: boolean;
|
WasMentioned?: boolean;
|
||||||
CommandAuthorized?: boolean;
|
CommandAuthorized?: boolean;
|
||||||
|
CommandSource?: "text" | "native";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TemplateContext = MsgContext & {
|
export type TemplateContext = MsgContext & {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const GROUP_LABELS: Record<string, string> = {
|
|||||||
models: "Models",
|
models: "Models",
|
||||||
routing: "Routing",
|
routing: "Routing",
|
||||||
messages: "Messages",
|
messages: "Messages",
|
||||||
|
commands: "Commands",
|
||||||
session: "Session",
|
session: "Session",
|
||||||
cron: "Cron",
|
cron: "Cron",
|
||||||
hooks: "Hooks",
|
hooks: "Hooks",
|
||||||
@@ -58,6 +59,7 @@ const GROUP_ORDER: Record<string, number> = {
|
|||||||
models: 50,
|
models: 50,
|
||||||
routing: 60,
|
routing: 60,
|
||||||
messages: 70,
|
messages: 70,
|
||||||
|
commands: 75,
|
||||||
session: 80,
|
session: 80,
|
||||||
cron: 90,
|
cron: 90,
|
||||||
hooks: 100,
|
hooks: 100,
|
||||||
@@ -94,6 +96,9 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"agent.model.fallbacks": "Model Fallbacks",
|
"agent.model.fallbacks": "Model Fallbacks",
|
||||||
"agent.imageModel.primary": "Image Model",
|
"agent.imageModel.primary": "Image Model",
|
||||||
"agent.imageModel.fallbacks": "Image Model Fallbacks",
|
"agent.imageModel.fallbacks": "Image Model Fallbacks",
|
||||||
|
"commands.native": "Native Commands",
|
||||||
|
"commands.text": "Text Commands",
|
||||||
|
"commands.useAccessGroups": "Use Access Groups",
|
||||||
"ui.seamColor": "Accent Color",
|
"ui.seamColor": "Accent Color",
|
||||||
"browser.controlUrl": "Browser Control URL",
|
"browser.controlUrl": "Browser Control URL",
|
||||||
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
|
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
|
||||||
@@ -137,6 +142,11 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"Optional image model (provider/model) used when the primary model lacks image input.",
|
"Optional image model (provider/model) used when the primary model lacks image input.",
|
||||||
"agent.imageModel.fallbacks":
|
"agent.imageModel.fallbacks":
|
||||||
"Ordered fallback image models (provider/model).",
|
"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":
|
"session.agentToAgent.maxPingPongTurns":
|
||||||
"Max reply-back turns between requester and target (0–5).",
|
"Max reply-back turns between requester and target (0–5).",
|
||||||
"messages.ackReaction":
|
"messages.ackReaction":
|
||||||
|
|||||||
@@ -300,17 +300,6 @@ export type DiscordGuildEntry = {
|
|||||||
channels?: Record<string, DiscordGuildChannelConfig>;
|
channels?: Record<string, DiscordGuildChannelConfig>;
|
||||||
};
|
};
|
||||||
|
|
||||||
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 = {
|
export type DiscordActionConfig = {
|
||||||
reactions?: boolean;
|
reactions?: boolean;
|
||||||
stickers?: boolean;
|
stickers?: boolean;
|
||||||
@@ -350,7 +339,6 @@ export type DiscordConfig = {
|
|||||||
actions?: DiscordActionConfig;
|
actions?: DiscordActionConfig;
|
||||||
/** Control reply threading when reply tags are present (off|first|all). */
|
/** Control reply threading when reply tags are present (off|first|all). */
|
||||||
replyToMode?: ReplyToMode;
|
replyToMode?: ReplyToMode;
|
||||||
slashCommand?: DiscordSlashCommandConfig;
|
|
||||||
dm?: DiscordDmConfig;
|
dm?: DiscordDmConfig;
|
||||||
/** New per-guild config keyed by guild id or slug. */
|
/** New per-guild config keyed by guild id or slug. */
|
||||||
guilds?: Record<string, DiscordGuildEntry>;
|
guilds?: Record<string, DiscordGuildEntry>;
|
||||||
@@ -577,6 +565,15 @@ export type MessagesConfig = {
|
|||||||
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all";
|
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 BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback";
|
||||||
|
|
||||||
export type BridgeConfig = {
|
export type BridgeConfig = {
|
||||||
@@ -998,6 +995,7 @@ export type ClawdbotConfig = {
|
|||||||
};
|
};
|
||||||
routing?: RoutingConfig;
|
routing?: RoutingConfig;
|
||||||
messages?: MessagesConfig;
|
messages?: MessagesConfig;
|
||||||
|
commands?: CommandsConfig;
|
||||||
session?: SessionConfig;
|
session?: SessionConfig;
|
||||||
web?: WebConfig;
|
web?: WebConfig;
|
||||||
whatsapp?: WhatsAppConfig;
|
whatsapp?: WhatsAppConfig;
|
||||||
|
|||||||
@@ -165,6 +165,14 @@ const MessagesSchema = z
|
|||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
|
const CommandsSchema = z
|
||||||
|
.object({
|
||||||
|
native: z.boolean().optional(),
|
||||||
|
text: z.boolean().optional(),
|
||||||
|
useAccessGroups: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
const HeartbeatSchema = z
|
const HeartbeatSchema = z
|
||||||
.object({
|
.object({
|
||||||
every: z.string().optional(),
|
every: z.string().optional(),
|
||||||
@@ -632,6 +640,7 @@ export const ClawdbotSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
routing: RoutingSchema,
|
routing: RoutingSchema,
|
||||||
messages: MessagesSchema,
|
messages: MessagesSchema,
|
||||||
|
commands: CommandsSchema,
|
||||||
session: SessionSchema,
|
session: SessionSchema,
|
||||||
cron: z
|
cron: z
|
||||||
.object({
|
.object({
|
||||||
@@ -786,14 +795,6 @@ export const ClawdbotSchema = z.object({
|
|||||||
token: z.string().optional(),
|
token: z.string().optional(),
|
||||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
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(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
historyLimit: z.number().int().min(0).optional(),
|
historyLimit: z.number().int().min(0).optional(),
|
||||||
actions: z
|
actions: z
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Guild } from "@buape/carbon";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
allowListMatches,
|
allowListMatches,
|
||||||
@@ -12,8 +13,7 @@ import {
|
|||||||
shouldEmitDiscordReactionNotification,
|
shouldEmitDiscordReactionNotification,
|
||||||
} from "./monitor.js";
|
} from "./monitor.js";
|
||||||
|
|
||||||
const fakeGuild = (id: string, name: string) =>
|
const fakeGuild = (id: string, name: string) => ({ id, name }) as Guild;
|
||||||
({ id, name }) as unknown as import("discord.js").Guild;
|
|
||||||
|
|
||||||
const makeEntries = (
|
const makeEntries = (
|
||||||
entries: Record<string, Partial<DiscordGuildEntryResolved>>,
|
entries: Record<string, Partial<DiscordGuildEntryResolved>>,
|
||||||
|
|||||||
@@ -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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { monitorDiscordProvider } from "./monitor.js";
|
|
||||||
|
|
||||||
const sendMock = vi.fn();
|
const sendMock = vi.fn();
|
||||||
const replyMock = vi.fn();
|
|
||||||
const updateLastRouteMock = vi.fn();
|
const updateLastRouteMock = vi.fn();
|
||||||
let config: Record<string, unknown> = {};
|
const dispatchMock = vi.fn();
|
||||||
const readAllowFromStoreMock = vi.fn();
|
|
||||||
const upsertPairingRequestMock = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("../config/config.js", async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
loadConfig: () => config,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("../auto-reply/reply.js", () => ({
|
|
||||||
getReplyFromConfig: (...args: unknown[]) => replyMock(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./send.js", () => ({
|
vi.mock("./send.js", () => ({
|
||||||
sendMessageDiscord: (...args: unknown[]) => sendMock(...args),
|
sendMessageDiscord: (...args: unknown[]) => sendMock(...args),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
|
||||||
vi.mock("../pairing/pairing-store.js", () => ({
|
dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args),
|
||||||
readProviderAllowFromStore: (...args: unknown[]) =>
|
|
||||||
readAllowFromStoreMock(...args),
|
|
||||||
upsertProviderPairingRequest: (...args: unknown[]) =>
|
|
||||||
upsertPairingRequestMock(...args),
|
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../config/sessions.js", async (importOriginal) => {
|
||||||
vi.mock("../config/sessions.js", () => ({
|
const actual = await importOriginal<typeof import("../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<string, Set<(...args: unknown[]) => 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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Client,
|
...actual,
|
||||||
__getLastClient: () => Client.lastClient,
|
resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"),
|
||||||
Events: {
|
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
|
||||||
ClientReady: "ready",
|
resolveSessionKey: vi.fn(),
|
||||||
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: {},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
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(() => {
|
beforeEach(() => {
|
||||||
config = {
|
|
||||||
messages: { responsePrefix: "PFX" },
|
|
||||||
discord: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } },
|
|
||||||
routing: { allowFrom: [] },
|
|
||||||
};
|
|
||||||
sendMock.mockReset().mockResolvedValue(undefined);
|
sendMock.mockReset().mockResolvedValue(undefined);
|
||||||
replyMock.mockReset();
|
|
||||||
updateLastRouteMock.mockReset();
|
updateLastRouteMock.mockReset();
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => {
|
||||||
upsertPairingRequestMock
|
dispatcher.sendFinalReply({ text: "hi" });
|
||||||
.mockReset()
|
return { queuedFinal: true, counts: { final: 1 } };
|
||||||
.mockResolvedValue({ code: "PAIRCODE", created: true });
|
});
|
||||||
|
vi.resetModules();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("monitorDiscordProvider tool results", () => {
|
describe("discord tool result dispatch", () => {
|
||||||
it("sends tool summaries with responsePrefix", async () => {
|
it("sends status replies with responsePrefix", async () => {
|
||||||
replyMock.mockImplementation(async (_ctx, opts) => {
|
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||||
await opts?.onToolResult?.({ text: "tool update" });
|
const cfg = {
|
||||||
return { text: "final reply" };
|
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<typeof import("../config/config.js").loadConfig>;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const runtimeError = vi.fn();
|
||||||
const run = monitorDiscordProvider({
|
const handler = createDiscordMessageHandler({
|
||||||
|
cfg,
|
||||||
token: "token",
|
token: "token",
|
||||||
abortSignal: controller.signal,
|
runtime: {
|
||||||
});
|
log: vi.fn(),
|
||||||
|
error: runtimeError,
|
||||||
const discord = await import("discord.js");
|
exit: (code: number): never => {
|
||||||
const client = await waitForClient();
|
throw new Error(`exit ${code}`);
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
guild: undefined,
|
botUserId: "bot-id",
|
||||||
mentions: { has: () => false },
|
guildHistories: new Map(),
|
||||||
attachments: { first: () => undefined },
|
historyLimit: 0,
|
||||||
type: discord.MessageType.Default,
|
mediaMaxBytes: 10_000,
|
||||||
createdTimestamp: Date.now(),
|
textLimit: 2000,
|
||||||
|
replyToMode: "off",
|
||||||
|
dmEnabled: true,
|
||||||
|
groupDmEnabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await flush();
|
const client = {
|
||||||
controller.abort();
|
fetchChannel: vi.fn().mockResolvedValue({
|
||||||
await run;
|
type: ChannelType.DM,
|
||||||
|
name: "dm",
|
||||||
|
}),
|
||||||
|
} as unknown as Client;
|
||||||
|
|
||||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
await handler(
|
||||||
expect(sendMock.mock.calls[0][1]).toBe("PFX tool update");
|
{
|
||||||
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
|
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 () => {
|
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" },
|
messages: { responsePrefix: "PFX" },
|
||||||
discord: {
|
discord: {
|
||||||
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
dm: { enabled: true, policy: "open" },
|
||||||
guilds: { "*": { requireMention: true } },
|
guilds: { "*": { requireMention: true } },
|
||||||
},
|
},
|
||||||
routing: {
|
routing: {
|
||||||
allowFrom: [],
|
allowFrom: [],
|
||||||
groupChat: { mentionPatterns: ["\\bclawd\\b"] },
|
groupChat: { mentionPatterns: ["\\bclawd\\b"] },
|
||||||
},
|
},
|
||||||
};
|
} as ReturnType<typeof import("../config/config.js").loadConfig>;
|
||||||
replyMock.mockResolvedValue({ text: "hi" });
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
const handler = createDiscordMessageHandler({
|
||||||
const run = monitorDiscordProvider({
|
cfg,
|
||||||
token: "token",
|
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 = {
|
||||||
const client = await waitForClient();
|
fetchChannel: vi.fn().mockResolvedValue({
|
||||||
if (!client) throw new Error("Discord client not created");
|
type: ChannelType.GuildText,
|
||||||
|
|
||||||
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,
|
|
||||||
name: "general",
|
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" },
|
client,
|
||||||
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",
|
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||||
|
}, 10000);
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -74,3 +74,27 @@ export async function probeDiscord(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchDiscordApplicationId(
|
||||||
|
token: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
fetcher: typeof fetch = fetch,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -53,7 +53,7 @@ const makeRest = () => {
|
|||||||
get: getMock,
|
get: getMock,
|
||||||
patch: patchMock,
|
patch: patchMock,
|
||||||
delete: deleteMock,
|
delete: deleteMock,
|
||||||
} as unknown as import("discord.js").REST,
|
} as unknown as import("@buape/carbon").RequestClient,
|
||||||
postMock,
|
postMock,
|
||||||
putMock,
|
putMock,
|
||||||
getMock,
|
getMock,
|
||||||
@@ -108,9 +108,7 @@ describe("sendMessageDiscord", () => {
|
|||||||
|
|
||||||
it("adds missing permission hints on 50013", async () => {
|
it("adds missing permission hints on 50013", async () => {
|
||||||
const { rest, postMock, getMock } = makeRest();
|
const { rest, postMock, getMock } = makeRest();
|
||||||
const perms = new PermissionsBitField([
|
const perms = PermissionFlagsBits.ViewChannel;
|
||||||
PermissionsBitField.Flags.ViewChannel,
|
|
||||||
]);
|
|
||||||
const apiError = Object.assign(new Error("Missing Permissions"), {
|
const apiError = Object.assign(new Error("Missing Permissions"), {
|
||||||
code: 50013,
|
code: 50013,
|
||||||
status: 403,
|
status: 403,
|
||||||
@@ -126,7 +124,7 @@ describe("sendMessageDiscord", () => {
|
|||||||
.mockResolvedValueOnce({ id: "bot1" })
|
.mockResolvedValueOnce({ id: "bot1" })
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
id: "guild1",
|
id: "guild1",
|
||||||
roles: [{ id: "guild1", permissions: perms.bitfield.toString() }],
|
roles: [{ id: "guild1", permissions: perms.toString() }],
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({ roles: [] });
|
.mockResolvedValueOnce({ roles: [] });
|
||||||
|
|
||||||
@@ -152,7 +150,9 @@ describe("sendMessageDiscord", () => {
|
|||||||
expect(postMock).toHaveBeenCalledWith(
|
expect(postMock).toHaveBeenCalledWith(
|
||||||
Routes.channelMessages("789"),
|
Routes.channelMessages("789"),
|
||||||
expect.objectContaining({
|
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 () => {
|
it("calculates permissions from guild roles", async () => {
|
||||||
const { rest, getMock } = makeRest();
|
const { rest, getMock } = makeRest();
|
||||||
const perms = new PermissionsBitField([
|
const perms =
|
||||||
PermissionsBitField.Flags.ViewChannel,
|
PermissionFlagsBits.ViewChannel | PermissionFlagsBits.SendMessages;
|
||||||
PermissionsBitField.Flags.SendMessages,
|
|
||||||
]);
|
|
||||||
getMock
|
getMock
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
id: "chan1",
|
id: "chan1",
|
||||||
@@ -282,7 +280,7 @@ describe("fetchChannelPermissionsDiscord", () => {
|
|||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
id: "guild1",
|
id: "guild1",
|
||||||
roles: [
|
roles: [
|
||||||
{ id: "guild1", permissions: perms.bitfield.toString() },
|
{ id: "guild1", permissions: perms.toString() },
|
||||||
{ id: "role2", permissions: "0" },
|
{ id: "role2", permissions: "0" },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -303,7 +301,7 @@ describe("readMessagesDiscord", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes query params as URLSearchParams", async () => {
|
it("passes query params as an object", async () => {
|
||||||
const { rest, getMock } = makeRest();
|
const { rest, getMock } = makeRest();
|
||||||
getMock.mockResolvedValue([]);
|
getMock.mockResolvedValue([]);
|
||||||
await readMessagesDiscord(
|
await readMessagesDiscord(
|
||||||
@@ -312,8 +310,8 @@ describe("readMessagesDiscord", () => {
|
|||||||
{ rest, token: "t" },
|
{ rest, token: "t" },
|
||||||
);
|
);
|
||||||
const call = getMock.mock.calls[0];
|
const call = getMock.mock.calls[0];
|
||||||
const options = call?.[1] as { query?: URLSearchParams };
|
const options = call?.[1] as Record<string, unknown>;
|
||||||
expect(options.query?.toString()).toBe("limit=5&before=10");
|
expect(options).toEqual({ limit: 5, before: "10" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -376,8 +374,7 @@ describe("searchMessagesDiscord", () => {
|
|||||||
{ rest, token: "t" },
|
{ rest, token: "t" },
|
||||||
);
|
);
|
||||||
const call = getMock.mock.calls[0];
|
const call = getMock.mock.calls[0];
|
||||||
const options = call?.[1] as { query?: URLSearchParams };
|
expect(call?.[0]).toBe("/guilds/g1/messages/search?content=hello&limit=5");
|
||||||
expect(options.query?.toString()).toBe("content=hello&limit=5");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports channel/author arrays and clamps limit", async () => {
|
it("supports channel/author arrays and clamps limit", async () => {
|
||||||
@@ -394,9 +391,8 @@ describe("searchMessagesDiscord", () => {
|
|||||||
{ rest, token: "t" },
|
{ rest, token: "t" },
|
||||||
);
|
);
|
||||||
const call = getMock.mock.calls[0];
|
const call = getMock.mock.calls[0];
|
||||||
const options = call?.[1] as { query?: URLSearchParams };
|
expect(call?.[0]).toBe(
|
||||||
expect(options.query?.toString()).toBe(
|
"/guilds/g1/messages/search?content=hello&channel_id=c1&channel_id=c2&author_id=u1&limit=25",
|
||||||
"content=hello&channel_id=c1&channel_id=c2&author_id=u1&limit=25",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -546,13 +542,13 @@ describe("uploadStickerDiscord", () => {
|
|||||||
name: "clawdbot_wave",
|
name: "clawdbot_wave",
|
||||||
description: "Clawdbot waving",
|
description: "Clawdbot waving",
|
||||||
tags: "👋",
|
tags: "👋",
|
||||||
|
files: [
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "asset.png",
|
||||||
|
contentType: "image/png",
|
||||||
|
}),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
files: [
|
|
||||||
expect.objectContaining({
|
|
||||||
name: "asset.png",
|
|
||||||
contentType: "image/png",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { PollLayoutType } from "discord-api-types/payloads/v10";
|
||||||
import type { RESTAPIPoll } from "discord-api-types/rest/v10";
|
import type { RESTAPIPoll } from "discord-api-types/rest/v10";
|
||||||
import type {
|
import type {
|
||||||
@@ -11,6 +11,11 @@ import type {
|
|||||||
APIVoiceState,
|
APIVoiceState,
|
||||||
RESTPostAPIGuildScheduledEventJSONBody,
|
RESTPostAPIGuildScheduledEventJSONBody,
|
||||||
} from "discord-api-types/v10";
|
} from "discord-api-types/v10";
|
||||||
|
import {
|
||||||
|
ChannelType,
|
||||||
|
PermissionFlagsBits,
|
||||||
|
Routes,
|
||||||
|
} from "discord-api-types/v10";
|
||||||
|
|
||||||
import { chunkMarkdownText } from "../auto-reply/chunk.js";
|
import { chunkMarkdownText } from "../auto-reply/chunk.js";
|
||||||
import { loadConfig } from "../config/config.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 =
|
type DiscordRecipient =
|
||||||
| {
|
| {
|
||||||
kind: "user";
|
kind: "user";
|
||||||
@@ -61,7 +70,7 @@ type DiscordSendOpts = {
|
|||||||
token?: string;
|
token?: string;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
rest?: REST;
|
rest?: RequestClient;
|
||||||
replyTo?: string;
|
replyTo?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,7 +81,7 @@ export type DiscordSendResult = {
|
|||||||
|
|
||||||
export type DiscordReactOpts = {
|
export type DiscordReactOpts = {
|
||||||
token?: string;
|
token?: string;
|
||||||
rest?: REST;
|
rest?: RequestClient;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DiscordReactionUser = {
|
export type DiscordReactionUser = {
|
||||||
@@ -174,6 +183,10 @@ function resolveToken(explicit?: string) {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRest(token: string, rest?: RequestClient) {
|
||||||
|
return rest ?? new RequestClient(token);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeReactionEmoji(raw: string) {
|
function normalizeReactionEmoji(raw: string) {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) {
|
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) {
|
function getDiscordErrorCode(err: unknown) {
|
||||||
if (!err || typeof err !== "object") return undefined;
|
if (!err || typeof err !== "object") return undefined;
|
||||||
const candidate =
|
const candidate =
|
||||||
@@ -279,7 +308,7 @@ async function buildDiscordSendError(
|
|||||||
err: unknown,
|
err: unknown,
|
||||||
ctx: {
|
ctx: {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
rest: REST;
|
rest: RequestClient;
|
||||||
token: string;
|
token: string;
|
||||||
hasMedia: boolean;
|
hasMedia: boolean;
|
||||||
},
|
},
|
||||||
@@ -327,7 +356,7 @@ async function buildDiscordSendError(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveChannelId(
|
async function resolveChannelId(
|
||||||
rest: REST,
|
rest: RequestClient,
|
||||||
recipient: DiscordRecipient,
|
recipient: DiscordRecipient,
|
||||||
): Promise<{ channelId: string; dm?: boolean }> {
|
): Promise<{ channelId: string; dm?: boolean }> {
|
||||||
if (recipient.kind === "channel") {
|
if (recipient.kind === "channel") {
|
||||||
@@ -343,7 +372,7 @@ async function resolveChannelId(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sendDiscordText(
|
async function sendDiscordText(
|
||||||
rest: REST,
|
rest: RequestClient,
|
||||||
channelId: string,
|
channelId: string,
|
||||||
text: string,
|
text: string,
|
||||||
replyTo?: string,
|
replyTo?: string,
|
||||||
@@ -379,7 +408,7 @@ async function sendDiscordText(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sendDiscordMedia(
|
async function sendDiscordMedia(
|
||||||
rest: REST,
|
rest: RequestClient,
|
||||||
channelId: string,
|
channelId: string,
|
||||||
text: string,
|
text: string,
|
||||||
mediaUrl: string,
|
mediaUrl: string,
|
||||||
@@ -395,13 +424,13 @@ async function sendDiscordMedia(
|
|||||||
body: {
|
body: {
|
||||||
content: caption || undefined,
|
content: caption || undefined,
|
||||||
message_reference: messageReference,
|
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 };
|
})) as { id: string; channel_id: string };
|
||||||
if (text.length > DISCORD_TEXT_LIMIT) {
|
if (text.length > DISCORD_TEXT_LIMIT) {
|
||||||
const remaining = text.slice(DISCORD_TEXT_LIMIT).trim();
|
const remaining = text.slice(DISCORD_TEXT_LIMIT).trim();
|
||||||
@@ -429,7 +458,7 @@ function formatReactionEmoji(emoji: {
|
|||||||
return buildReactionIdentifier(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 };
|
const me = (await rest.get(Routes.user("@me"))) as { id?: string };
|
||||||
if (!me?.id) {
|
if (!me?.id) {
|
||||||
throw new Error("Failed to resolve bot user id");
|
throw new Error("Failed to resolve bot user id");
|
||||||
@@ -443,7 +472,7 @@ export async function sendMessageDiscord(
|
|||||||
opts: DiscordSendOpts = {},
|
opts: DiscordSendOpts = {},
|
||||||
): Promise<DiscordSendResult> {
|
): Promise<DiscordSendResult> {
|
||||||
const token = resolveToken(opts.token);
|
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 recipient = parseRecipient(to);
|
||||||
const { channelId } = await resolveChannelId(rest, recipient);
|
const { channelId } = await resolveChannelId(rest, recipient);
|
||||||
let result:
|
let result:
|
||||||
@@ -482,7 +511,7 @@ export async function sendStickerDiscord(
|
|||||||
opts: DiscordSendOpts & { content?: string } = {},
|
opts: DiscordSendOpts & { content?: string } = {},
|
||||||
): Promise<DiscordSendResult> {
|
): Promise<DiscordSendResult> {
|
||||||
const token = resolveToken(opts.token);
|
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 recipient = parseRecipient(to);
|
||||||
const { channelId } = await resolveChannelId(rest, recipient);
|
const { channelId } = await resolveChannelId(rest, recipient);
|
||||||
const content = opts.content?.trim();
|
const content = opts.content?.trim();
|
||||||
@@ -505,7 +534,7 @@ export async function sendPollDiscord(
|
|||||||
opts: DiscordSendOpts & { content?: string } = {},
|
opts: DiscordSendOpts & { content?: string } = {},
|
||||||
): Promise<DiscordSendResult> {
|
): Promise<DiscordSendResult> {
|
||||||
const token = resolveToken(opts.token);
|
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 recipient = parseRecipient(to);
|
||||||
const { channelId } = await resolveChannelId(rest, recipient);
|
const { channelId } = await resolveChannelId(rest, recipient);
|
||||||
const content = opts.content?.trim();
|
const content = opts.content?.trim();
|
||||||
@@ -529,7 +558,7 @@ export async function reactMessageDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
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);
|
const encoded = normalizeReactionEmoji(emoji);
|
||||||
await rest.put(
|
await rest.put(
|
||||||
Routes.channelMessageOwnReaction(channelId, messageId, encoded),
|
Routes.channelMessageOwnReaction(channelId, messageId, encoded),
|
||||||
@@ -543,7 +572,7 @@ export async function fetchReactionsDiscord(
|
|||||||
opts: DiscordReactOpts & { limit?: number } = {},
|
opts: DiscordReactOpts & { limit?: number } = {},
|
||||||
): Promise<DiscordReactionSummary[]> {
|
): Promise<DiscordReactionSummary[]> {
|
||||||
const token = resolveToken(opts.token);
|
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(
|
const message = (await rest.get(
|
||||||
Routes.channelMessage(channelId, messageId),
|
Routes.channelMessage(channelId, messageId),
|
||||||
)) as {
|
)) as {
|
||||||
@@ -566,7 +595,7 @@ export async function fetchReactionsDiscord(
|
|||||||
const encoded = encodeURIComponent(identifier);
|
const encoded = encodeURIComponent(identifier);
|
||||||
const users = (await rest.get(
|
const users = (await rest.get(
|
||||||
Routes.channelMessageReaction(channelId, messageId, encoded),
|
Routes.channelMessageReaction(channelId, messageId, encoded),
|
||||||
{ query: new URLSearchParams({ limit: String(limit) }) },
|
{ limit },
|
||||||
)) as Array<{ id: string; username?: string; discriminator?: string }>;
|
)) as Array<{ id: string; username?: string; discriminator?: string }>;
|
||||||
summaries.push({
|
summaries.push({
|
||||||
emoji: {
|
emoji: {
|
||||||
@@ -593,7 +622,7 @@ export async function fetchChannelPermissionsDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<DiscordPermissionsSummary> {
|
): Promise<DiscordPermissionsSummary> {
|
||||||
const token = resolveToken(opts.token);
|
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 channel = (await rest.get(Routes.channel(channelId))) as APIChannel;
|
||||||
const channelType = "type" in channel ? channel.type : undefined;
|
const channelType = "type" in channel ? channel.type : undefined;
|
||||||
const guildId = "guild_id" in channel ? channel.guild_id : undefined;
|
const guildId = "guild_id" in channel ? channel.guild_id : undefined;
|
||||||
@@ -616,47 +645,47 @@ export async function fetchChannelPermissionsDiscord(
|
|||||||
const rolesById = new Map<string, APIRole>(
|
const rolesById = new Map<string, APIRole>(
|
||||||
(guild.roles ?? []).map((role) => [role.id, role]),
|
(guild.roles ?? []).map((role) => [role.id, role]),
|
||||||
);
|
);
|
||||||
const base = new PermissionsBitField();
|
|
||||||
const everyoneRole = rolesById.get(guildId);
|
const everyoneRole = rolesById.get(guildId);
|
||||||
|
let base = 0n;
|
||||||
if (everyoneRole?.permissions) {
|
if (everyoneRole?.permissions) {
|
||||||
base.add(BigInt(everyoneRole.permissions));
|
base = addPermissionBits(base, everyoneRole.permissions);
|
||||||
}
|
}
|
||||||
for (const roleId of member.roles ?? []) {
|
for (const roleId of member.roles ?? []) {
|
||||||
const role = rolesById.get(roleId);
|
const role = rolesById.get(roleId);
|
||||||
if (role?.permissions) {
|
if (role?.permissions) {
|
||||||
base.add(BigInt(role.permissions));
|
base = addPermissionBits(base, role.permissions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const permissions = new PermissionsBitField(base);
|
let permissions = base;
|
||||||
const overwrites =
|
const overwrites =
|
||||||
"permission_overwrites" in channel
|
"permission_overwrites" in channel
|
||||||
? (channel.permission_overwrites ?? [])
|
? (channel.permission_overwrites ?? [])
|
||||||
: [];
|
: [];
|
||||||
for (const overwrite of overwrites) {
|
for (const overwrite of overwrites) {
|
||||||
if (overwrite.id === guildId) {
|
if (overwrite.id === guildId) {
|
||||||
permissions.remove(BigInt(overwrite.deny ?? "0"));
|
permissions = removePermissionBits(permissions, overwrite.deny ?? "0");
|
||||||
permissions.add(BigInt(overwrite.allow ?? "0"));
|
permissions = addPermissionBits(permissions, overwrite.allow ?? "0");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const overwrite of overwrites) {
|
for (const overwrite of overwrites) {
|
||||||
if (member.roles?.includes(overwrite.id)) {
|
if (member.roles?.includes(overwrite.id)) {
|
||||||
permissions.remove(BigInt(overwrite.deny ?? "0"));
|
permissions = removePermissionBits(permissions, overwrite.deny ?? "0");
|
||||||
permissions.add(BigInt(overwrite.allow ?? "0"));
|
permissions = addPermissionBits(permissions, overwrite.allow ?? "0");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const overwrite of overwrites) {
|
for (const overwrite of overwrites) {
|
||||||
if (overwrite.id === botId) {
|
if (overwrite.id === botId) {
|
||||||
permissions.remove(BigInt(overwrite.deny ?? "0"));
|
permissions = removePermissionBits(permissions, overwrite.deny ?? "0");
|
||||||
permissions.add(BigInt(overwrite.allow ?? "0"));
|
permissions = addPermissionBits(permissions, overwrite.allow ?? "0");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
channelId,
|
channelId,
|
||||||
guildId,
|
guildId,
|
||||||
permissions: permissions.toArray(),
|
permissions: bitfieldToPermissions(permissions),
|
||||||
raw: permissions.bitfield.toString(),
|
raw: permissions.toString(),
|
||||||
isDm: false,
|
isDm: false,
|
||||||
channelType,
|
channelType,
|
||||||
};
|
};
|
||||||
@@ -668,19 +697,20 @@ export async function readMessagesDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIMessage[]> {
|
): Promise<APIMessage[]> {
|
||||||
const token = resolveToken(opts.token);
|
const token = resolveToken(opts.token);
|
||||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
const rest = resolveRest(token, opts.rest);
|
||||||
const limit =
|
const limit =
|
||||||
typeof query.limit === "number" && Number.isFinite(query.limit)
|
typeof query.limit === "number" && Number.isFinite(query.limit)
|
||||||
? Math.min(Math.max(Math.floor(query.limit), 1), 100)
|
? Math.min(Math.max(Math.floor(query.limit), 1), 100)
|
||||||
: undefined;
|
: undefined;
|
||||||
const params = new URLSearchParams();
|
const params: Record<string, string | number> = {};
|
||||||
if (limit) params.set("limit", String(limit));
|
if (limit) params.limit = limit;
|
||||||
if (query.before) params.set("before", query.before);
|
if (query.before) params.before = query.before;
|
||||||
if (query.after) params.set("after", query.after);
|
if (query.after) params.after = query.after;
|
||||||
if (query.around) params.set("around", query.around);
|
if (query.around) params.around = query.around;
|
||||||
return (await rest.get(Routes.channelMessages(channelId), {
|
return (await rest.get(
|
||||||
query: params,
|
Routes.channelMessages(channelId),
|
||||||
})) as APIMessage[];
|
params,
|
||||||
|
)) as APIMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function editMessageDiscord(
|
export async function editMessageDiscord(
|
||||||
@@ -690,7 +720,7 @@ export async function editMessageDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIMessage> {
|
): Promise<APIMessage> {
|
||||||
const token = resolveToken(opts.token);
|
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), {
|
return (await rest.patch(Routes.channelMessage(channelId, messageId), {
|
||||||
body: { content: payload.content },
|
body: { content: payload.content },
|
||||||
})) as APIMessage;
|
})) as APIMessage;
|
||||||
@@ -702,7 +732,7 @@ export async function deleteMessageDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
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));
|
await rest.delete(Routes.channelMessage(channelId, messageId));
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@@ -713,7 +743,7 @@ export async function pinMessageDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
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));
|
await rest.put(Routes.channelPin(channelId, messageId));
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@@ -724,7 +754,7 @@ export async function unpinMessageDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
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));
|
await rest.delete(Routes.channelPin(channelId, messageId));
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@@ -734,7 +764,7 @@ export async function listPinsDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIMessage[]> {
|
): Promise<APIMessage[]> {
|
||||||
const token = resolveToken(opts.token);
|
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[];
|
return (await rest.get(Routes.channelPins(channelId))) as APIMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -744,7 +774,7 @@ export async function createThreadDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
const token = resolveToken(opts.token);
|
||||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
const rest = resolveRest(token, opts.rest);
|
||||||
const body: Record<string, unknown> = { name: payload.name };
|
const body: Record<string, unknown> = { name: payload.name };
|
||||||
if (payload.autoArchiveMinutes) {
|
if (payload.autoArchiveMinutes) {
|
||||||
body.auto_archive_duration = payload.autoArchiveMinutes;
|
body.auto_archive_duration = payload.autoArchiveMinutes;
|
||||||
@@ -758,17 +788,18 @@ export async function listThreadsDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
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.includeArchived) {
|
||||||
if (!payload.channelId) {
|
if (!payload.channelId) {
|
||||||
throw new Error("channelId required to list archived threads");
|
throw new Error("channelId required to list archived threads");
|
||||||
}
|
}
|
||||||
const params = new URLSearchParams();
|
const params: Record<string, string | number> = {};
|
||||||
if (payload.before) params.set("before", payload.before);
|
if (payload.before) params.before = payload.before;
|
||||||
if (payload.limit) params.set("limit", String(payload.limit));
|
if (payload.limit) params.limit = payload.limit;
|
||||||
return await rest.get(Routes.channelThreads(payload.channelId, "public"), {
|
return await rest.get(
|
||||||
query: params,
|
Routes.channelThreads(payload.channelId, "public"),
|
||||||
});
|
params,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return await rest.get(Routes.guildActiveThreads(payload.guildId));
|
return await rest.get(Routes.guildActiveThreads(payload.guildId));
|
||||||
}
|
}
|
||||||
@@ -778,7 +809,7 @@ export async function searchMessagesDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
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();
|
const params = new URLSearchParams();
|
||||||
params.set("content", query.content);
|
params.set("content", query.content);
|
||||||
if (query.channelIds?.length) {
|
if (query.channelIds?.length) {
|
||||||
@@ -795,9 +826,9 @@ export async function searchMessagesDiscord(
|
|||||||
const limit = Math.min(Math.max(Math.floor(query.limit), 1), 25);
|
const limit = Math.min(Math.max(Math.floor(query.limit), 1), 25);
|
||||||
params.set("limit", String(limit));
|
params.set("limit", String(limit));
|
||||||
}
|
}
|
||||||
return await rest.get(`/guilds/${query.guildId}/messages/search`, {
|
return await rest.get(
|
||||||
query: params,
|
`/guilds/${query.guildId}/messages/search?${params.toString()}`,
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listGuildEmojisDiscord(
|
export async function listGuildEmojisDiscord(
|
||||||
@@ -805,7 +836,7 @@ export async function listGuildEmojisDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
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));
|
return await rest.get(Routes.guildEmojis(guildId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -814,7 +845,7 @@ export async function uploadEmojiDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
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(
|
const media = await loadWebMediaRaw(
|
||||||
payload.mediaUrl,
|
payload.mediaUrl,
|
||||||
DISCORD_MAX_EMOJI_BYTES,
|
DISCORD_MAX_EMOJI_BYTES,
|
||||||
@@ -844,7 +875,7 @@ export async function uploadStickerDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
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(
|
const media = await loadWebMediaRaw(
|
||||||
payload.mediaUrl,
|
payload.mediaUrl,
|
||||||
DISCORD_MAX_STICKER_BYTES,
|
DISCORD_MAX_STICKER_BYTES,
|
||||||
@@ -866,14 +897,14 @@ export async function uploadStickerDiscord(
|
|||||||
"Sticker description",
|
"Sticker description",
|
||||||
),
|
),
|
||||||
tags: normalizeEmojiName(payload.tags, "Sticker tags"),
|
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 = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIGuildMember> {
|
): Promise<APIGuildMember> {
|
||||||
const token = resolveToken(opts.token);
|
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(
|
return (await rest.get(
|
||||||
Routes.guildMember(guildId, userId),
|
Routes.guildMember(guildId, userId),
|
||||||
)) as APIGuildMember;
|
)) as APIGuildMember;
|
||||||
@@ -894,7 +925,7 @@ export async function fetchRoleInfoDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIRole[]> {
|
): Promise<APIRole[]> {
|
||||||
const token = resolveToken(opts.token);
|
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[];
|
return (await rest.get(Routes.guildRoles(guildId))) as APIRole[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -903,7 +934,7 @@ export async function addRoleDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
const token = resolveToken(opts.token);
|
||||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
const rest = resolveRest(token, opts.rest);
|
||||||
await rest.put(
|
await rest.put(
|
||||||
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
|
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
|
||||||
);
|
);
|
||||||
@@ -915,7 +946,7 @@ export async function removeRoleDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
const token = resolveToken(opts.token);
|
||||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
const rest = resolveRest(token, opts.rest);
|
||||||
await rest.delete(
|
await rest.delete(
|
||||||
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
|
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
|
||||||
);
|
);
|
||||||
@@ -927,7 +958,7 @@ export async function fetchChannelInfoDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIChannel> {
|
): Promise<APIChannel> {
|
||||||
const token = resolveToken(opts.token);
|
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;
|
return (await rest.get(Routes.channel(channelId))) as APIChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -936,7 +967,7 @@ export async function listGuildChannelsDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIChannel[]> {
|
): Promise<APIChannel[]> {
|
||||||
const token = resolveToken(opts.token);
|
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[];
|
return (await rest.get(Routes.guildChannels(guildId))) as APIChannel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -946,7 +977,7 @@ export async function fetchVoiceStatusDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIVoiceState> {
|
): Promise<APIVoiceState> {
|
||||||
const token = resolveToken(opts.token);
|
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(
|
return (await rest.get(
|
||||||
Routes.guildVoiceState(guildId, userId),
|
Routes.guildVoiceState(guildId, userId),
|
||||||
)) as APIVoiceState;
|
)) as APIVoiceState;
|
||||||
@@ -957,7 +988,7 @@ export async function listScheduledEventsDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIGuildScheduledEvent[]> {
|
): Promise<APIGuildScheduledEvent[]> {
|
||||||
const token = resolveToken(opts.token);
|
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(
|
return (await rest.get(
|
||||||
Routes.guildScheduledEvents(guildId),
|
Routes.guildScheduledEvents(guildId),
|
||||||
)) as APIGuildScheduledEvent[];
|
)) as APIGuildScheduledEvent[];
|
||||||
@@ -969,7 +1000,7 @@ export async function createScheduledEventDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIGuildScheduledEvent> {
|
): Promise<APIGuildScheduledEvent> {
|
||||||
const token = resolveToken(opts.token);
|
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), {
|
return (await rest.post(Routes.guildScheduledEvents(guildId), {
|
||||||
body: payload,
|
body: payload,
|
||||||
})) as APIGuildScheduledEvent;
|
})) as APIGuildScheduledEvent;
|
||||||
@@ -980,7 +1011,7 @@ export async function timeoutMemberDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIGuildMember> {
|
): Promise<APIGuildMember> {
|
||||||
const token = resolveToken(opts.token);
|
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;
|
let until = payload.until;
|
||||||
if (!until && payload.durationMinutes) {
|
if (!until && payload.durationMinutes) {
|
||||||
const ms = payload.durationMinutes * 60 * 1000;
|
const ms = payload.durationMinutes * 60 * 1000;
|
||||||
@@ -990,7 +1021,9 @@ export async function timeoutMemberDiscord(
|
|||||||
Routes.guildMember(payload.guildId, payload.userId),
|
Routes.guildMember(payload.guildId, payload.userId),
|
||||||
{
|
{
|
||||||
body: { communication_disabled_until: until ?? null },
|
body: { communication_disabled_until: until ?? null },
|
||||||
reason: payload.reason,
|
headers: payload.reason
|
||||||
|
? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) }
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
)) as APIGuildMember;
|
)) as APIGuildMember;
|
||||||
}
|
}
|
||||||
@@ -1000,9 +1033,11 @@ export async function kickMemberDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
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), {
|
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 };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@@ -1012,7 +1047,7 @@ export async function banMemberDiscord(
|
|||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
const token = resolveToken(opts.token);
|
||||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
const rest = resolveRest(token, opts.rest);
|
||||||
const deleteMessageDays =
|
const deleteMessageDays =
|
||||||
typeof payload.deleteMessageDays === "number" &&
|
typeof payload.deleteMessageDays === "number" &&
|
||||||
Number.isFinite(payload.deleteMessageDays)
|
Number.isFinite(payload.deleteMessageDays)
|
||||||
@@ -1023,7 +1058,9 @@ export async function banMemberDiscord(
|
|||||||
deleteMessageDays !== undefined
|
deleteMessageDays !== undefined
|
||||||
? { delete_message_days: deleteMessageDays }
|
? { delete_message_days: deleteMessageDays }
|
||||||
: undefined,
|
: undefined,
|
||||||
reason: payload.reason,
|
headers: payload.reason
|
||||||
|
? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) }
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -473,7 +473,6 @@ export function createProviderManager(
|
|||||||
token: discordToken.trim(),
|
token: discordToken.trim(),
|
||||||
runtime: discordRuntimeEnv,
|
runtime: discordRuntimeEnv,
|
||||||
abortSignal: discordAbort.signal,
|
abortSignal: discordAbort.signal,
|
||||||
slashCommand: cfg.discord?.slashCommand,
|
|
||||||
mediaMaxMb: cfg.discord?.mediaMaxMb,
|
mediaMaxMb: cfg.discord?.mediaMaxMb,
|
||||||
historyLimit: cfg.discord?.historyLimit,
|
historyLimit: cfg.discord?.historyLimit,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -248,7 +248,6 @@ describe("google-shared convertMessages", () => {
|
|||||||
} as unknown as Context;
|
} as unknown as Context;
|
||||||
|
|
||||||
const contents = convertMessages(model, context);
|
const contents = convertMessages(model, context);
|
||||||
// Should merge into a single user message
|
|
||||||
expect(contents).toHaveLength(1);
|
expect(contents).toHaveLength(1);
|
||||||
expect(contents[0].role).toBe("user");
|
expect(contents[0].role).toBe("user");
|
||||||
expect(contents[0].parts).toHaveLength(2);
|
expect(contents[0].parts).toHaveLength(2);
|
||||||
@@ -333,7 +332,6 @@ describe("google-shared convertMessages", () => {
|
|||||||
} as unknown as Context;
|
} as unknown as Context;
|
||||||
|
|
||||||
const contents = convertMessages(model, context);
|
const contents = convertMessages(model, context);
|
||||||
// Should have 1 user + 1 merged model message
|
|
||||||
expect(contents).toHaveLength(2);
|
expect(contents).toHaveLength(2);
|
||||||
expect(contents[0].role).toBe("user");
|
expect(contents[0].role).toBe("user");
|
||||||
expect(contents[1].role).toBe("model");
|
expect(contents[1].role).toBe("model");
|
||||||
@@ -394,17 +392,16 @@ describe("google-shared convertMessages", () => {
|
|||||||
} as unknown as Context;
|
} as unknown as Context;
|
||||||
|
|
||||||
const contents = convertMessages(model, context);
|
const contents = convertMessages(model, context);
|
||||||
// Tool result creates a user turn with functionResponse
|
expect(contents).toHaveLength(3);
|
||||||
// The next user message should be merged into it or there should be proper alternation
|
expect(contents[0].role).toBe("user");
|
||||||
// Check that we don't have consecutive user messages
|
expect(contents[1].role).toBe("model");
|
||||||
for (let i = 1; i < contents.length; i++) {
|
expect(contents[2].role).toBe("user");
|
||||||
if (contents[i].role === "user" && contents[i - 1].role === "user") {
|
const toolResponsePart = contents[2].parts?.find(
|
||||||
// If consecutive, they should have been merged
|
(part) =>
|
||||||
expect.fail("Consecutive user messages should be merged");
|
typeof part === "object" && part !== null && "functionResponse" in part,
|
||||||
}
|
);
|
||||||
}
|
const toolResponse = asRecord(toolResponsePart);
|
||||||
// The conversation should be valid for Gemini
|
expect(toolResponse.functionResponse).toBeTruthy();
|
||||||
expect(contents.length).toBeGreaterThan(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ensures function call comes after user turn, not after model turn", () => {
|
it("ensures function call comes after user turn, not after model turn", () => {
|
||||||
@@ -472,11 +469,14 @@ describe("google-shared convertMessages", () => {
|
|||||||
} as unknown as Context;
|
} as unknown as Context;
|
||||||
|
|
||||||
const contents = convertMessages(model, 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).toHaveLength(2);
|
||||||
expect(contents[0].role).toBe("user");
|
expect(contents[0].role).toBe("user");
|
||||||
expect(contents[1].role).toBe("model");
|
expect(contents[1].role).toBe("model");
|
||||||
// The model message should have both text and function call
|
const toolCallPart = contents[1].parts?.find(
|
||||||
expect(contents[1].parts?.length).toBe(2);
|
(part) =>
|
||||||
|
typeof part === "object" && part !== null && "functionCall" in part,
|
||||||
|
);
|
||||||
|
const toolCall = asRecord(toolCallPart);
|
||||||
|
expect(toolCall.functionCall).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import {
|
|||||||
resolveTextChunkLimit,
|
resolveTextChunkLimit,
|
||||||
} from "../auto-reply/chunk.js";
|
} from "../auto-reply/chunk.js";
|
||||||
import { hasControlCommand } from "../auto-reply/command-detection.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 { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||||
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
|
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
|
||||||
import {
|
import {
|
||||||
@@ -389,6 +394,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
const channelsConfig = cfg.slack?.channels;
|
const channelsConfig = cfg.slack?.channels;
|
||||||
const dmEnabled = dmConfig?.enabled ?? true;
|
const dmEnabled = dmConfig?.enabled ?? true;
|
||||||
const groupPolicy = cfg.slack?.groupPolicy ?? "open";
|
const groupPolicy = cfg.slack?.groupPolicy ?? "open";
|
||||||
|
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||||
const reactionMode = cfg.slack?.reactionNotifications ?? "own";
|
const reactionMode = cfg.slack?.reactionNotifications ?? "own";
|
||||||
const reactionAllowlist = cfg.slack?.reactionAllowlist ?? [];
|
const reactionAllowlist = cfg.slack?.reactionAllowlist ?? [];
|
||||||
const slashCommand = resolveSlackSlashCommandConfig(
|
const slashCommand = resolveSlackSlashCommandConfig(
|
||||||
@@ -672,7 +678,12 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
name: senderName,
|
name: senderName,
|
||||||
});
|
});
|
||||||
const hasAnyMention = /<@[^>]+>/.test(message.text ?? "");
|
const hasAnyMention = /<@[^>]+>/.test(message.text ?? "");
|
||||||
|
const allowTextCommands = shouldHandleTextCommands({
|
||||||
|
cfg,
|
||||||
|
surface: "slack",
|
||||||
|
});
|
||||||
const shouldBypassMention =
|
const shouldBypassMention =
|
||||||
|
allowTextCommands &&
|
||||||
isRoom &&
|
isRoom &&
|
||||||
channelConfig?.requireMention &&
|
channelConfig?.requireMention &&
|
||||||
!wasMentioned &&
|
!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 <code>",
|
||||||
|
].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(
|
app.command(
|
||||||
slashCommand.name,
|
slashCommand.name,
|
||||||
async ({ command, ack, respond }: SlackCommandMiddlewareArgs) => {
|
async ({ command, ack, respond }: SlackCommandMiddlewareArgs) => {
|
||||||
try {
|
await handleSlashCommand({
|
||||||
const prompt = command.text?.trim();
|
command,
|
||||||
if (!prompt) {
|
ack,
|
||||||
await ack({
|
respond,
|
||||||
text: "Message required.",
|
prompt: command.text?.trim() ?? "",
|
||||||
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 <code>",
|
|
||||||
].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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const onSpy = vi.fn();
|
|||||||
const stopSpy = vi.fn();
|
const stopSpy = vi.fn();
|
||||||
const sendChatActionSpy = vi.fn();
|
const sendChatActionSpy = vi.fn();
|
||||||
const setMessageReactionSpy = vi.fn(async () => undefined);
|
const setMessageReactionSpy = vi.fn(async () => undefined);
|
||||||
|
const setMyCommandsSpy = vi.fn(async () => undefined);
|
||||||
const sendMessageSpy = vi.fn(async () => ({ message_id: 77 }));
|
const sendMessageSpy = vi.fn(async () => ({ message_id: 77 }));
|
||||||
const sendAnimationSpy = vi.fn(async () => ({ message_id: 78 }));
|
const sendAnimationSpy = vi.fn(async () => ({ message_id: 78 }));
|
||||||
const sendPhotoSpy = vi.fn(async () => ({ message_id: 79 }));
|
const sendPhotoSpy = vi.fn(async () => ({ message_id: 79 }));
|
||||||
@@ -48,6 +49,7 @@ type ApiStub = {
|
|||||||
config: { use: (arg: unknown) => void };
|
config: { use: (arg: unknown) => void };
|
||||||
sendChatAction: typeof sendChatActionSpy;
|
sendChatAction: typeof sendChatActionSpy;
|
||||||
setMessageReaction: typeof setMessageReactionSpy;
|
setMessageReaction: typeof setMessageReactionSpy;
|
||||||
|
setMyCommands: typeof setMyCommandsSpy;
|
||||||
sendMessage: typeof sendMessageSpy;
|
sendMessage: typeof sendMessageSpy;
|
||||||
sendAnimation: typeof sendAnimationSpy;
|
sendAnimation: typeof sendAnimationSpy;
|
||||||
sendPhoto: typeof sendPhotoSpy;
|
sendPhoto: typeof sendPhotoSpy;
|
||||||
@@ -56,6 +58,7 @@ const apiStub: ApiStub = {
|
|||||||
config: { use: useSpy },
|
config: { use: useSpy },
|
||||||
sendChatAction: sendChatActionSpy,
|
sendChatAction: sendChatActionSpy,
|
||||||
setMessageReaction: setMessageReactionSpy,
|
setMessageReaction: setMessageReactionSpy,
|
||||||
|
setMyCommands: setMyCommandsSpy,
|
||||||
sendMessage: sendMessageSpy,
|
sendMessage: sendMessageSpy,
|
||||||
sendAnimation: sendAnimationSpy,
|
sendAnimation: sendAnimationSpy,
|
||||||
sendPhoto: sendPhotoSpy,
|
sendPhoto: sendPhotoSpy,
|
||||||
@@ -95,6 +98,7 @@ describe("createTelegramBot", () => {
|
|||||||
sendAnimationSpy.mockReset();
|
sendAnimationSpy.mockReset();
|
||||||
sendPhotoSpy.mockReset();
|
sendPhotoSpy.mockReset();
|
||||||
setMessageReactionSpy.mockReset();
|
setMessageReactionSpy.mockReset();
|
||||||
|
setMyCommandsSpy.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("installs grammY throttler", () => {
|
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 () => {
|
it("skips group messages when requireMention is enabled and no mention matches", async () => {
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
resolveTextChunkLimit,
|
resolveTextChunkLimit,
|
||||||
} from "../auto-reply/chunk.js";
|
} from "../auto-reply/chunk.js";
|
||||||
import { hasControlCommand } from "../auto-reply/command-detection.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 { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||||
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
|
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
|
||||||
import {
|
import {
|
||||||
@@ -160,6 +164,9 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "off";
|
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 ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
||||||
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||||
const mediaMaxBytes =
|
const mediaMaxBytes =
|
||||||
@@ -483,6 +490,139 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
if (!queuedFinal) return;
|
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) => {
|
bot.on("message", async (ctx) => {
|
||||||
try {
|
try {
|
||||||
const msg = ctx.message;
|
const msg = ctx.message;
|
||||||
|
|||||||
Reference in New Issue
Block a user