Merge branch 'clawdbot:main' into main
This commit is contained in:
commit
dc13145ccf
@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
## 2026.1.25
|
## 2026.1.26
|
||||||
Status: unreleased.
|
Status: unreleased.
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
- Rebrand: rename the npm package/CLI to `moltbot`, add a `clawdbot` compatibility shim, and move extensions to the `@moltbot/*` scope.
|
||||||
|
- Commands: group /help and /commands output with Telegram paging. (#2504) Thanks @hougangdev.
|
||||||
- macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk).
|
- macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk).
|
||||||
|
- Tools: add per-sender group tool policies and fix precedence. (#1757) Thanks @adam91holt.
|
||||||
- Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47.
|
- Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47.
|
||||||
- Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204.
|
- Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204.
|
||||||
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
|
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
|
||||||
@ -52,6 +55,8 @@ Status: unreleased.
|
|||||||
- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
|
- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
|
||||||
- Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma.
|
- Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma.
|
||||||
- Telegram: support editing sent messages via message(action="edit"). (#2394) Thanks @marcelomar21.
|
- Telegram: support editing sent messages via message(action="edit"). (#2394) Thanks @marcelomar21.
|
||||||
|
- Telegram: add sticker receive/send with vision caching. (#2629) Thanks @longjos.
|
||||||
|
- Telegram: send sticker pixels to vision models. (#2650)
|
||||||
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
|
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
|
||||||
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
|
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
|
||||||
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
|
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
|
||||||
@ -60,6 +65,7 @@ Status: unreleased.
|
|||||||
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355.
|
||||||
- macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee.
|
- macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee.
|
||||||
- Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg.
|
- Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg.
|
||||||
- TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg.
|
- TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg.
|
||||||
|
|||||||
@ -21,8 +21,8 @@ android {
|
|||||||
applicationId = "com.clawdbot.android"
|
applicationId = "com.clawdbot.android"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 202601250
|
versionCode = 202601260
|
||||||
versionName = "2026.1.25"
|
versionName = "2026.1.26"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@ -19,9 +19,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.25</string>
|
<string>2026.1.26</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20260125</string>
|
<string>20260126</string>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||||
|
|||||||
@ -17,8 +17,8 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>BNDL</string>
|
<string>BNDL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.25</string>
|
<string>2026.1.26</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20260125</string>
|
<string>20260126</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@ -81,8 +81,8 @@ targets:
|
|||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: Clawdbot
|
CFBundleDisplayName: Clawdbot
|
||||||
CFBundleIconName: AppIcon
|
CFBundleIconName: AppIcon
|
||||||
CFBundleShortVersionString: "2026.1.25"
|
CFBundleShortVersionString: "2026.1.26"
|
||||||
CFBundleVersion: "20260125"
|
CFBundleVersion: "20260126"
|
||||||
UILaunchScreen: {}
|
UILaunchScreen: {}
|
||||||
UIApplicationSceneManifest:
|
UIApplicationSceneManifest:
|
||||||
UIApplicationSupportsMultipleScenes: false
|
UIApplicationSupportsMultipleScenes: false
|
||||||
@ -130,5 +130,5 @@ targets:
|
|||||||
path: Tests/Info.plist
|
path: Tests/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: ClawdbotTests
|
CFBundleDisplayName: ClawdbotTests
|
||||||
CFBundleShortVersionString: "2026.1.25"
|
CFBundleShortVersionString: "2026.1.26"
|
||||||
CFBundleVersion: "20260125"
|
CFBundleVersion: "20260126"
|
||||||
|
|||||||
@ -15,9 +15,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.25</string>
|
<string>2026.1.26</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>202601250</string>
|
<string>202601260</string>
|
||||||
<key>CFBundleIconFile</key>
|
<key>CFBundleIconFile</key>
|
||||||
<string>Clawdbot</string>
|
<string>Clawdbot</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
|
|||||||
@ -218,6 +218,7 @@ Prefer `chat_guid` for stable routing:
|
|||||||
## Security
|
## Security
|
||||||
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
|
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
|
||||||
- Keep the API password and webhook endpoint secret (treat them like credentials).
|
- Keep the API password and webhook endpoint secret (treat them like credentials).
|
||||||
|
- Localhost trust means a same-host reverse proxy can unintentionally bypass the password. If you proxy the gateway, require auth at the proxy and configure `gateway.trustedProxies`. See [Gateway security](/gateway/security#reverse-proxy-configuration).
|
||||||
- Enable HTTPS + firewall rules on the BlueBubbles server if exposing it outside your LAN.
|
- Enable HTTPS + firewall rules on the BlueBubbles server if exposing it outside your LAN.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|||||||
@ -298,8 +298,12 @@ ack reaction after the bot replies.
|
|||||||
- `guilds."*"`: default per-guild settings applied when no explicit entry exists.
|
- `guilds."*"`: default per-guild settings applied when no explicit entry exists.
|
||||||
- `guilds.<id>.slug`: optional friendly slug used for display names.
|
- `guilds.<id>.slug`: optional friendly slug used for display names.
|
||||||
- `guilds.<id>.users`: optional per-guild user allowlist (ids or names).
|
- `guilds.<id>.users`: optional per-guild user allowlist (ids or names).
|
||||||
|
- `guilds.<id>.tools`: optional per-guild tool policy overrides (`allow`/`deny`/`alsoAllow`) used when the channel override is missing.
|
||||||
|
- `guilds.<id>.toolsBySender`: optional per-sender tool policy overrides at the guild level (applies when the channel override is missing; `"*"` wildcard supported).
|
||||||
- `guilds.<id>.channels.<channel>.allow`: allow/deny the channel when `groupPolicy="allowlist"`.
|
- `guilds.<id>.channels.<channel>.allow`: allow/deny the channel when `groupPolicy="allowlist"`.
|
||||||
- `guilds.<id>.channels.<channel>.requireMention`: mention gating for the channel.
|
- `guilds.<id>.channels.<channel>.requireMention`: mention gating for the channel.
|
||||||
|
- `guilds.<id>.channels.<channel>.tools`: optional per-channel tool policy overrides (`allow`/`deny`/`alsoAllow`).
|
||||||
|
- `guilds.<id>.channels.<channel>.toolsBySender`: optional per-sender tool policy overrides within the channel (`"*"` wildcard supported).
|
||||||
- `guilds.<id>.channels.<channel>.users`: optional per-channel user allowlist.
|
- `guilds.<id>.channels.<channel>.users`: optional per-channel user allowlist.
|
||||||
- `guilds.<id>.channels.<channel>.skills`: skill filter (omit = all skills, empty = none).
|
- `guilds.<id>.channels.<channel>.skills`: skill filter (omit = all skills, empty = none).
|
||||||
- `guilds.<id>.channels.<channel>.systemPrompt`: extra system prompt for the channel (combined with channel topic).
|
- `guilds.<id>.channels.<channel>.systemPrompt`: extra system prompt for the channel (combined with channel topic).
|
||||||
|
|||||||
@ -421,8 +421,12 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
|
|||||||
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
|
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
|
||||||
- `channels.msteams.teams.<teamId>.replyStyle`: per-team override.
|
- `channels.msteams.teams.<teamId>.replyStyle`: per-team override.
|
||||||
- `channels.msteams.teams.<teamId>.requireMention`: per-team override.
|
- `channels.msteams.teams.<teamId>.requireMention`: per-team override.
|
||||||
|
- `channels.msteams.teams.<teamId>.tools`: default per-team tool policy overrides (`allow`/`deny`/`alsoAllow`) used when a channel override is missing.
|
||||||
|
- `channels.msteams.teams.<teamId>.toolsBySender`: default per-team per-sender tool policy overrides (`"*"` wildcard supported).
|
||||||
- `channels.msteams.teams.<teamId>.channels.<conversationId>.replyStyle`: per-channel override.
|
- `channels.msteams.teams.<teamId>.channels.<conversationId>.replyStyle`: per-channel override.
|
||||||
- `channels.msteams.teams.<teamId>.channels.<conversationId>.requireMention`: per-channel override.
|
- `channels.msteams.teams.<teamId>.channels.<conversationId>.requireMention`: per-channel override.
|
||||||
|
- `channels.msteams.teams.<teamId>.channels.<conversationId>.tools`: per-channel tool policy overrides (`allow`/`deny`/`alsoAllow`).
|
||||||
|
- `channels.msteams.teams.<teamId>.channels.<conversationId>.toolsBySender`: per-channel per-sender tool policy overrides (`"*"` wildcard supported).
|
||||||
- `channels.msteams.sharePointSiteId`: SharePoint site ID for file uploads in group chats/channels (see [Sending files in group chats](#sending-files-in-group-chats)).
|
- `channels.msteams.sharePointSiteId`: SharePoint site ID for file uploads in group chats/channels (see [Sending files in group chats](#sending-files-in-group-chats)).
|
||||||
|
|
||||||
## Routing & Sessions
|
## Routing & Sessions
|
||||||
|
|||||||
@ -464,6 +464,8 @@ For fine-grained control, use these tags in agent responses:
|
|||||||
Channel options (`channels.slack.channels.<id>` or `channels.slack.channels.<name>`):
|
Channel options (`channels.slack.channels.<id>` or `channels.slack.channels.<name>`):
|
||||||
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`.
|
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`.
|
||||||
- `requireMention`: mention gating for the channel.
|
- `requireMention`: mention gating for the channel.
|
||||||
|
- `tools`: optional per-channel tool policy overrides (`allow`/`deny`/`alsoAllow`).
|
||||||
|
- `toolsBySender`: optional per-sender tool policy overrides within the channel (keys are sender ids/@handles/emails; `"*"` wildcard supported).
|
||||||
- `allowBots`: allow bot-authored messages in this channel (default: false).
|
- `allowBots`: allow bot-authored messages in this channel (default: false).
|
||||||
- `users`: optional per-channel user allowlist.
|
- `users`: optional per-channel user allowlist.
|
||||||
- `skills`: skill filter (omit = all skills, empty = none).
|
- `skills`: skill filter (omit = all skills, empty = none).
|
||||||
|
|||||||
@ -383,6 +383,133 @@ For message tool sends, set `asVoice: true` with a voice-compatible audio `media
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Stickers
|
||||||
|
|
||||||
|
Clawdbot supports receiving and sending Telegram stickers with intelligent caching.
|
||||||
|
|
||||||
|
### Receiving stickers
|
||||||
|
|
||||||
|
When a user sends a sticker, Clawdbot handles it based on the sticker type:
|
||||||
|
|
||||||
|
- **Static stickers (WEBP):** Downloaded and processed through vision. The sticker appears as a `<media:sticker>` placeholder in the message content.
|
||||||
|
- **Animated stickers (TGS):** Skipped (Lottie format not supported for processing).
|
||||||
|
- **Video stickers (WEBM):** Skipped (video format not supported for processing).
|
||||||
|
|
||||||
|
Template context field available when receiving stickers:
|
||||||
|
- `Sticker` — object with:
|
||||||
|
- `emoji` — emoji associated with the sticker
|
||||||
|
- `setName` — name of the sticker set
|
||||||
|
- `fileId` — Telegram file ID (send the same sticker back)
|
||||||
|
- `fileUniqueId` — stable ID for cache lookup
|
||||||
|
- `cachedDescription` — cached vision description when available
|
||||||
|
|
||||||
|
### Sticker cache
|
||||||
|
|
||||||
|
Stickers are processed through the AI's vision capabilities to generate descriptions. Since the same stickers are often sent repeatedly, Clawdbot caches these descriptions to avoid redundant API calls.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
|
||||||
|
1. **First encounter:** The sticker image is sent to the AI for vision analysis. The AI generates a description (e.g., "A cartoon cat waving enthusiastically").
|
||||||
|
2. **Cache storage:** The description is saved along with the sticker's file ID, emoji, and set name.
|
||||||
|
3. **Subsequent encounters:** When the same sticker is seen again, the cached description is used directly. The image is not sent to the AI.
|
||||||
|
|
||||||
|
**Cache location:** `~/.clawdbot/telegram/sticker-cache.json`
|
||||||
|
|
||||||
|
**Cache entry format:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fileId": "CAACAgIAAxkBAAI...",
|
||||||
|
"fileUniqueId": "AgADBAADb6cxG2Y",
|
||||||
|
"emoji": "👋",
|
||||||
|
"setName": "CoolCats",
|
||||||
|
"description": "A cartoon cat waving enthusiastically",
|
||||||
|
"cachedAt": "2026-01-15T10:30:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Reduces API costs by avoiding repeated vision calls for the same sticker
|
||||||
|
- Faster response times for cached stickers (no vision processing delay)
|
||||||
|
- Enables sticker search functionality based on cached descriptions
|
||||||
|
|
||||||
|
The cache is populated automatically as stickers are received. There is no manual cache management required.
|
||||||
|
|
||||||
|
### Sending stickers
|
||||||
|
|
||||||
|
The agent can send and search stickers using the `sticker` and `sticker-search` actions. These are disabled by default and must be enabled in config:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
actions: {
|
||||||
|
sticker: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Send a sticker:**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"action": "sticker",
|
||||||
|
"channel": "telegram",
|
||||||
|
"to": "123456789",
|
||||||
|
"fileId": "CAACAgIAAxkBAAI..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- `fileId` (required) — the Telegram file ID of the sticker. Obtain this from `Sticker.fileId` when receiving a sticker, or from a `sticker-search` result.
|
||||||
|
- `replyTo` (optional) — message ID to reply to.
|
||||||
|
- `threadId` (optional) — message thread ID for forum topics.
|
||||||
|
|
||||||
|
**Search for stickers:**
|
||||||
|
|
||||||
|
The agent can search cached stickers by description, emoji, or set name:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"action": "sticker-search",
|
||||||
|
"channel": "telegram",
|
||||||
|
"query": "cat waving",
|
||||||
|
"limit": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns matching stickers from the cache:
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"count": 2,
|
||||||
|
"stickers": [
|
||||||
|
{
|
||||||
|
"fileId": "CAACAgIAAxkBAAI...",
|
||||||
|
"emoji": "👋",
|
||||||
|
"description": "A cartoon cat waving enthusiastically",
|
||||||
|
"setName": "CoolCats"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The search uses fuzzy matching across description text, emoji characters, and set names.
|
||||||
|
|
||||||
|
**Example with threading:**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"action": "sticker",
|
||||||
|
"channel": "telegram",
|
||||||
|
"to": "-1001234567890",
|
||||||
|
"fileId": "CAACAgIAAxkBAAI...",
|
||||||
|
"replyTo": 42,
|
||||||
|
"threadId": 123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Streaming (drafts)
|
## Streaming (drafts)
|
||||||
Telegram can stream **draft bubbles** while the agent is generating a response.
|
Telegram can stream **draft bubbles** while the agent is generating a response.
|
||||||
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
|
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
|
||||||
@ -420,7 +547,7 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti
|
|||||||
- Tool: `telegram` with `react` action (`chatId`, `messageId`, `emoji`).
|
- Tool: `telegram` with `react` action (`chatId`, `messageId`, `emoji`).
|
||||||
- Tool: `telegram` with `deleteMessage` action (`chatId`, `messageId`).
|
- Tool: `telegram` with `deleteMessage` action (`chatId`, `messageId`).
|
||||||
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
|
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
|
||||||
- Tool gating: `channels.telegram.actions.reactions`, `channels.telegram.actions.sendMessage`, `channels.telegram.actions.deleteMessage` (default: enabled).
|
- Tool gating: `channels.telegram.actions.reactions`, `channels.telegram.actions.sendMessage`, `channels.telegram.actions.deleteMessage` (default: enabled), and `channels.telegram.actions.sticker` (default: disabled).
|
||||||
|
|
||||||
## Reaction notifications
|
## Reaction notifications
|
||||||
|
|
||||||
@ -537,6 +664,7 @@ Provider options:
|
|||||||
- `channels.telegram.actions.reactions`: gate Telegram tool reactions.
|
- `channels.telegram.actions.reactions`: gate Telegram tool reactions.
|
||||||
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
|
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
|
||||||
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
|
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
|
||||||
|
- `channels.telegram.actions.sticker`: gate Telegram sticker actions — send and search (default: false).
|
||||||
- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `own` when not set).
|
- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `own` when not set).
|
||||||
- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `minimal` when not set).
|
- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `minimal` when not set).
|
||||||
|
|
||||||
|
|||||||
@ -232,6 +232,42 @@ Notes:
|
|||||||
- Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel).
|
- Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel).
|
||||||
- Group history context is wrapped uniformly across channels and is **pending-only** (messages skipped due to mention gating); use `messages.groupChat.historyLimit` for the global default and `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit`) for overrides. Set `0` to disable.
|
- Group history context is wrapped uniformly across channels and is **pending-only** (messages skipped due to mention gating); use `messages.groupChat.historyLimit` for the global default and `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit`) for overrides. Set `0` to disable.
|
||||||
|
|
||||||
|
## Group/channel tool restrictions (optional)
|
||||||
|
Some channel configs support restricting which tools are available **inside a specific group/room/channel**.
|
||||||
|
|
||||||
|
- `tools`: allow/deny tools for the whole group.
|
||||||
|
- `toolsBySender`: per-sender overrides within the group (keys are sender IDs/usernames/emails/phone numbers depending on the channel). Use `"*"` as a wildcard.
|
||||||
|
|
||||||
|
Resolution order (most specific wins):
|
||||||
|
1) group/channel `toolsBySender` match
|
||||||
|
2) group/channel `tools`
|
||||||
|
3) default (`"*"`) `toolsBySender` match
|
||||||
|
4) default (`"*"`) `tools`
|
||||||
|
|
||||||
|
Example (Telegram):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
groups: {
|
||||||
|
"*": { tools: { deny: ["exec"] } },
|
||||||
|
"-1001234567890": {
|
||||||
|
tools: { deny: ["exec", "read", "write"] },
|
||||||
|
toolsBySender: {
|
||||||
|
"123456789": { alsoAllow: ["exec"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Group/channel tool restrictions are applied in addition to global/agent tool policy (deny still wins).
|
||||||
|
- Some channels use different nesting for rooms/channels (e.g., Discord `guilds.*.channels.*`, Slack `channels.*`, MS Teams `teams.*.channels.*`).
|
||||||
|
|
||||||
## Group allowlists
|
## Group allowlists
|
||||||
When `channels.whatsapp.groups`, `channels.telegram.groups`, or `channels.imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior.
|
When `channels.whatsapp.groups`, `channels.telegram.groups`, or `channels.imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior.
|
||||||
|
|
||||||
|
|||||||
107
docs/gateway/security/formal-verification.md
Normal file
107
docs/gateway/security/formal-verification.md
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
title: Formal Verification (Security Models)
|
||||||
|
summary: Machine-checked security models for Clawdbot’s highest-risk paths.
|
||||||
|
permalink: /gateway/security/formal-verification/
|
||||||
|
---
|
||||||
|
|
||||||
|
# Formal Verification (Security Models)
|
||||||
|
|
||||||
|
This page tracks Clawdbot’s **formal security models** (TLA+/TLC today; more as needed).
|
||||||
|
|
||||||
|
**Goal (north star):** provide a machine-checked argument that Clawdbot enforces its
|
||||||
|
intended security policy (authorization, session isolation, tool gating, and
|
||||||
|
misconfiguration safety), under explicit assumptions.
|
||||||
|
|
||||||
|
**What this is (today):** an executable, attacker-driven **security regression suite**:
|
||||||
|
- Each claim has a runnable model-check over a finite state space.
|
||||||
|
- Many claims have a paired **negative model** that produces a counterexample trace for a realistic bug class.
|
||||||
|
|
||||||
|
**What this is not (yet):** a proof that “Clawdbot is secure in all respects” or that the full TypeScript implementation is correct.
|
||||||
|
|
||||||
|
## Where the models live
|
||||||
|
|
||||||
|
Models are maintained in a separate repo: [vignesh07/clawdbot-formal-models](https://github.com/vignesh07/clawdbot-formal-models).
|
||||||
|
|
||||||
|
## Important caveats
|
||||||
|
|
||||||
|
- These are **models**, not the full TypeScript implementation. Drift between model and code is possible.
|
||||||
|
- Results are bounded by the state space explored by TLC; “green” does not imply security beyond the modeled assumptions and bounds.
|
||||||
|
- Some claims rely on explicit environmental assumptions (e.g., correct deployment, correct configuration inputs).
|
||||||
|
|
||||||
|
## Reproducing results
|
||||||
|
|
||||||
|
Today, results are reproduced by cloning the models repo locally and running TLC (see below). A future iteration could offer:
|
||||||
|
- CI-run models with public artifacts (counterexample traces, run logs)
|
||||||
|
- a hosted “run this model” workflow for small, bounded checks
|
||||||
|
|
||||||
|
Getting started:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/vignesh07/clawdbot-formal-models
|
||||||
|
cd clawdbot-formal-models
|
||||||
|
|
||||||
|
# Java 11+ required (TLC runs on the JVM).
|
||||||
|
# The repo vendors a pinned `tla2tools.jar` (TLA+ tools) and provides `bin/tlc` + Make targets.
|
||||||
|
|
||||||
|
make <target>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gateway exposure and open gateway misconfiguration
|
||||||
|
|
||||||
|
**Claim:** binding beyond loopback without auth can make remote compromise possible / increases exposure; token/password blocks unauth attackers (per the model assumptions).
|
||||||
|
|
||||||
|
- Green runs:
|
||||||
|
- `make gateway-exposure-v2`
|
||||||
|
- `make gateway-exposure-v2-protected`
|
||||||
|
- Red (expected):
|
||||||
|
- `make gateway-exposure-v2-negative`
|
||||||
|
|
||||||
|
See also: `docs/gateway-exposure-matrix.md` in the models repo.
|
||||||
|
|
||||||
|
### Nodes.run pipeline (highest-risk capability)
|
||||||
|
|
||||||
|
**Claim:** `nodes.run` requires (a) node command allowlist plus declared commands and (b) live approval when configured; approvals are tokenized to prevent replay (in the model).
|
||||||
|
|
||||||
|
- Green runs:
|
||||||
|
- `make nodes-pipeline`
|
||||||
|
- `make approvals-token`
|
||||||
|
- Red (expected):
|
||||||
|
- `make nodes-pipeline-negative`
|
||||||
|
- `make approvals-token-negative`
|
||||||
|
|
||||||
|
### Pairing store (DM gating)
|
||||||
|
|
||||||
|
**Claim:** pairing requests respect TTL and pending-request caps.
|
||||||
|
|
||||||
|
- Green runs:
|
||||||
|
- `make pairing`
|
||||||
|
- `make pairing-cap`
|
||||||
|
- Red (expected):
|
||||||
|
- `make pairing-negative`
|
||||||
|
- `make pairing-cap-negative`
|
||||||
|
|
||||||
|
### Ingress gating (mentions + control-command bypass)
|
||||||
|
|
||||||
|
**Claim:** in group contexts requiring mention, an unauthorized “control command” cannot bypass mention gating.
|
||||||
|
|
||||||
|
- Green:
|
||||||
|
- `make ingress-gating`
|
||||||
|
- Red (expected):
|
||||||
|
- `make ingress-gating-negative`
|
||||||
|
|
||||||
|
### Routing/session-key isolation
|
||||||
|
|
||||||
|
**Claim:** DMs from distinct peers do not collapse into the same session unless explicitly linked/configured.
|
||||||
|
|
||||||
|
- Green:
|
||||||
|
- `make routing-isolation`
|
||||||
|
- Red (expected):
|
||||||
|
- `make routing-isolation-negative`
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
Next models to deepen fidelity:
|
||||||
|
- Pairing store concurrency/locking/idempotency
|
||||||
|
- Provider-specific ingress preflight modeling
|
||||||
|
- Routing identity-links + dmScope variants + binding precedence
|
||||||
|
- Gateway auth conformance (proxy/tailscale specifics)
|
||||||
@ -185,7 +185,7 @@ cat > /data/clawdbot.json << 'EOF'
|
|||||||
"bind": "auto"
|
"bind": "auto"
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"lastTouchedVersion": "2026.1.25"
|
"lastTouchedVersion": "2026.1.26"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@ -30,17 +30,17 @@ Notes:
|
|||||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||||
BUNDLE_ID=com.clawdbot.mac \
|
BUNDLE_ID=com.clawdbot.mac \
|
||||||
APP_VERSION=2026.1.25 \
|
APP_VERSION=2026.1.26 \
|
||||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||||
BUILD_CONFIG=release \
|
BUILD_CONFIG=release \
|
||||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||||
scripts/package-mac-app.sh
|
scripts/package-mac-app.sh
|
||||||
|
|
||||||
# Zip for distribution (includes resource forks for Sparkle delta support)
|
# Zip for distribution (includes resource forks for Sparkle delta support)
|
||||||
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.25.zip
|
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.26.zip
|
||||||
|
|
||||||
# Optional: also build a styled DMG for humans (drag to /Applications)
|
# Optional: also build a styled DMG for humans (drag to /Applications)
|
||||||
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.25.dmg
|
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.26.dmg
|
||||||
|
|
||||||
# Recommended: build + notarize/staple zip + DMG
|
# Recommended: build + notarize/staple zip + DMG
|
||||||
# First, create a keychain profile once:
|
# First, create a keychain profile once:
|
||||||
@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.25.dmg
|
|||||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||||
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
|
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
|
||||||
BUNDLE_ID=com.clawdbot.mac \
|
BUNDLE_ID=com.clawdbot.mac \
|
||||||
APP_VERSION=2026.1.25 \
|
APP_VERSION=2026.1.26 \
|
||||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||||
BUILD_CONFIG=release \
|
BUILD_CONFIG=release \
|
||||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||||
scripts/package-mac-dist.sh
|
scripts/package-mac-dist.sh
|
||||||
|
|
||||||
# Optional: ship dSYM alongside the release
|
# Optional: ship dSYM alongside the release
|
||||||
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.25.dSYM.zip
|
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.26.dSYM.zip
|
||||||
```
|
```
|
||||||
|
|
||||||
## Appcast entry
|
## Appcast entry
|
||||||
Use the release note generator so Sparkle renders formatted HTML notes:
|
Use the release note generator so Sparkle renders formatted HTML notes:
|
||||||
```bash
|
```bash
|
||||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.25.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.26.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
||||||
```
|
```
|
||||||
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
||||||
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
|
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
|
||||||
|
|
||||||
## Publish & verify
|
## Publish & verify
|
||||||
- Upload `Clawdbot-2026.1.25.zip` (and `Clawdbot-2026.1.25.dSYM.zip`) to the GitHub release for tag `v2026.1.25`.
|
- Upload `Clawdbot-2026.1.26.zip` (and `Clawdbot-2026.1.26.dSYM.zip`) to the GitHub release for tag `v2026.1.26`.
|
||||||
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
|
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
|
||||||
- Sanity checks:
|
- Sanity checks:
|
||||||
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
|
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
|
||||||
|
|||||||
@ -17,7 +17,7 @@ When the operator says “release”, immediately do this preflight (no extra qu
|
|||||||
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
|
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
|
||||||
|
|
||||||
1) **Version & metadata**
|
1) **Version & metadata**
|
||||||
- [ ] Bump `package.json` version (e.g., `2026.1.25`).
|
- [ ] Bump `package.json` version (e.g., `2026.1.26`).
|
||||||
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
|
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
|
||||||
- [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/provider-web.ts).
|
- [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/provider-web.ts).
|
||||||
- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js) for `clawdbot`.
|
- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js) for `clawdbot`.
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/bluebubbles",
|
"name": "@moltbot/bluebubbles",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot BlueBubbles channel plugin",
|
"description": "Clawdbot BlueBubbles channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
@ -25,7 +25,7 @@
|
|||||||
"order": 75
|
"order": 75
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"npmSpec": "@clawdbot/bluebubbles",
|
"npmSpec": "@moltbot/bluebubbles",
|
||||||
"localPath": "extensions/bluebubbles",
|
"localPath": "extensions/bluebubbles",
|
||||||
"defaultChoice": "npm"
|
"defaultChoice": "npm"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/copilot-proxy",
|
"name": "@moltbot/copilot-proxy",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Copilot Proxy provider plugin",
|
"description": "Clawdbot Copilot Proxy provider plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/diagnostics-otel",
|
"name": "@moltbot/diagnostics-otel",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot diagnostics OpenTelemetry exporter",
|
"description": "Clawdbot diagnostics OpenTelemetry exporter",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/discord",
|
"name": "@moltbot/discord",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Discord channel plugin",
|
"description": "Clawdbot Discord channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/google-antigravity-auth",
|
"name": "@moltbot/google-antigravity-auth",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Google Antigravity OAuth provider plugin",
|
"description": "Clawdbot Google Antigravity OAuth provider plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/google-gemini-cli-auth",
|
"name": "@moltbot/google-gemini-cli-auth",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Gemini CLI OAuth provider plugin",
|
"description": "Clawdbot Gemini CLI OAuth provider plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/googlechat",
|
"name": "@moltbot/googlechat",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Google Chat channel plugin",
|
"description": "Clawdbot Google Chat channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
@ -22,7 +22,7 @@
|
|||||||
"order": 55
|
"order": 55
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"npmSpec": "@clawdbot/googlechat",
|
"npmSpec": "@moltbot/googlechat",
|
||||||
"localPath": "extensions/googlechat",
|
"localPath": "extensions/googlechat",
|
||||||
"defaultChoice": "npm"
|
"defaultChoice": "npm"
|
||||||
}
|
}
|
||||||
@ -34,6 +34,6 @@
|
|||||||
"clawdbot": "workspace:*"
|
"clawdbot": "workspace:*"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"clawdbot": ">=2026.1.25"
|
"clawdbot": ">=2026.1.26"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/imessage",
|
"name": "@moltbot/imessage",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot iMessage channel plugin",
|
"description": "Clawdbot iMessage channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/line",
|
"name": "@moltbot/line",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot LINE channel plugin",
|
"description": "Clawdbot LINE channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
@ -18,7 +18,7 @@
|
|||||||
"quickstartAllowFrom": true
|
"quickstartAllowFrom": true
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"npmSpec": "@clawdbot/line",
|
"npmSpec": "@moltbot/line",
|
||||||
"localPath": "extensions/line",
|
"localPath": "extensions/line",
|
||||||
"defaultChoice": "npm"
|
"defaultChoice": "npm"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/llm-task",
|
"name": "@moltbot/llm-task",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot JSON-only LLM task plugin",
|
"description": "Clawdbot JSON-only LLM task plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/lobster",
|
"name": "@moltbot/lobster",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/matrix",
|
"name": "@moltbot/matrix",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Matrix channel plugin",
|
"description": "Clawdbot Matrix channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
@ -18,7 +18,7 @@
|
|||||||
"quickstartAllowFrom": true
|
"quickstartAllowFrom": true
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"npmSpec": "@clawdbot/matrix",
|
"npmSpec": "@moltbot/matrix",
|
||||||
"localPath": "extensions/matrix",
|
"localPath": "extensions/matrix",
|
||||||
"defaultChoice": "npm"
|
"defaultChoice": "npm"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/mattermost",
|
"name": "@moltbot/mattermost",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Mattermost channel plugin",
|
"description": "Clawdbot Mattermost channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
@ -17,7 +17,7 @@
|
|||||||
"order": 65
|
"order": 65
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"npmSpec": "@clawdbot/mattermost",
|
"npmSpec": "@moltbot/mattermost",
|
||||||
"localPath": "extensions/mattermost",
|
"localPath": "extensions/mattermost",
|
||||||
"defaultChoice": "npm"
|
"defaultChoice": "npm"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/memory-core",
|
"name": "@moltbot/memory-core",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot core memory search plugin",
|
"description": "Clawdbot core memory search plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
@ -9,6 +9,6 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"clawdbot": ">=2026.1.24-3"
|
"clawdbot": ">=2026.1.26"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/memory-lancedb",
|
"name": "@moltbot/memory-lancedb",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
|
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/msteams",
|
"name": "@moltbot/msteams",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Microsoft Teams channel plugin",
|
"description": "Clawdbot Microsoft Teams channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
@ -20,7 +20,7 @@
|
|||||||
"order": 60
|
"order": 60
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"npmSpec": "@clawdbot/msteams",
|
"npmSpec": "@moltbot/msteams",
|
||||||
"localPath": "extensions/msteams",
|
"localPath": "extensions/msteams",
|
||||||
"defaultChoice": "npm"
|
"defaultChoice": "npm"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
buildChannelKeyCandidates,
|
buildChannelKeyCandidates,
|
||||||
normalizeChannelSlug,
|
normalizeChannelSlug,
|
||||||
|
resolveToolsBySender,
|
||||||
resolveChannelEntryMatchWithFallback,
|
resolveChannelEntryMatchWithFallback,
|
||||||
resolveNestedAllowlistDecision,
|
resolveNestedAllowlistDecision,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
@ -106,9 +107,36 @@ export function resolveMSTeamsGroupToolPolicy(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (resolved.channelConfig) {
|
if (resolved.channelConfig) {
|
||||||
return resolved.channelConfig.tools ?? resolved.teamConfig?.tools;
|
const senderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: resolved.channelConfig.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (senderPolicy) return senderPolicy;
|
||||||
|
if (resolved.channelConfig.tools) return resolved.channelConfig.tools;
|
||||||
|
const teamSenderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: resolved.teamConfig?.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (teamSenderPolicy) return teamSenderPolicy;
|
||||||
|
return resolved.teamConfig?.tools;
|
||||||
|
}
|
||||||
|
if (resolved.teamConfig) {
|
||||||
|
const teamSenderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: resolved.teamConfig.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (teamSenderPolicy) return teamSenderPolicy;
|
||||||
|
if (resolved.teamConfig.tools) return resolved.teamConfig.tools;
|
||||||
}
|
}
|
||||||
if (resolved.teamConfig?.tools) return resolved.teamConfig.tools;
|
|
||||||
|
|
||||||
if (!groupId) return undefined;
|
if (!groupId) return undefined;
|
||||||
|
|
||||||
@ -125,7 +153,24 @@ export function resolveMSTeamsGroupToolPolicy(
|
|||||||
normalizeKey: normalizeChannelSlug,
|
normalizeKey: normalizeChannelSlug,
|
||||||
});
|
});
|
||||||
if (match.entry) {
|
if (match.entry) {
|
||||||
return match.entry.tools ?? teamConfig?.tools;
|
const senderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: match.entry.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (senderPolicy) return senderPolicy;
|
||||||
|
if (match.entry.tools) return match.entry.tools;
|
||||||
|
const teamSenderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: teamConfig?.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (teamSenderPolicy) return teamSenderPolicy;
|
||||||
|
return teamConfig?.tools;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/nextcloud-talk",
|
"name": "@moltbot/nextcloud-talk",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Nextcloud Talk channel plugin",
|
"description": "Clawdbot Nextcloud Talk channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
@ -22,7 +22,7 @@
|
|||||||
"quickstartAllowFrom": true
|
"quickstartAllowFrom": true
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"npmSpec": "@clawdbot/nextcloud-talk",
|
"npmSpec": "@moltbot/nextcloud-talk",
|
||||||
"localPath": "extensions/nextcloud-talk",
|
"localPath": "extensions/nextcloud-talk",
|
||||||
"defaultChoice": "npm"
|
"defaultChoice": "npm"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/nostr",
|
"name": "@moltbot/nostr",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
|
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
@ -18,7 +18,7 @@
|
|||||||
"quickstartAllowFrom": true
|
"quickstartAllowFrom": true
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"npmSpec": "@clawdbot/nostr",
|
"npmSpec": "@moltbot/nostr",
|
||||||
"localPath": "extensions/nostr",
|
"localPath": "extensions/nostr",
|
||||||
"defaultChoice": "npm"
|
"defaultChoice": "npm"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/open-prose",
|
"name": "@moltbot/open-prose",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
|
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/signal",
|
"name": "@moltbot/signal",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Signal channel plugin",
|
"description": "Clawdbot Signal channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/slack",
|
"name": "@moltbot/slack",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Slack channel plugin",
|
"description": "Clawdbot Slack channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/telegram",
|
"name": "@moltbot/telegram",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Telegram channel plugin",
|
"description": "Clawdbot Telegram channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/tlon",
|
"name": "@moltbot/tlon",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Tlon/Urbit channel plugin",
|
"description": "Clawdbot Tlon/Urbit channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
@ -18,7 +18,7 @@
|
|||||||
"quickstartAllowFrom": true
|
"quickstartAllowFrom": true
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"npmSpec": "@clawdbot/tlon",
|
"npmSpec": "@moltbot/tlon",
|
||||||
"localPath": "extensions/tlon",
|
"localPath": "extensions/tlon",
|
||||||
"defaultChoice": "npm"
|
"defaultChoice": "npm"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/twitch",
|
"name": "@moltbot/twitch",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.26",
|
||||||
"description": "Clawdbot Twitch channel plugin",
|
"description": "Clawdbot Twitch channel plugin",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2026.1.25
|
## 2026.1.26
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core).
|
- Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core).
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/voice-call",
|
"name": "@moltbot/voice-call",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot voice-call plugin",
|
"description": "Clawdbot voice-call plugin",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/whatsapp",
|
"name": "@moltbot/whatsapp",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot WhatsApp channel plugin",
|
"description": "Clawdbot WhatsApp channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/zalo",
|
"name": "@moltbot/zalo",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Zalo channel plugin",
|
"description": "Clawdbot Zalo channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
@ -21,7 +21,7 @@
|
|||||||
"quickstartAllowFrom": true
|
"quickstartAllowFrom": true
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"npmSpec": "@clawdbot/zalo",
|
"npmSpec": "@moltbot/zalo",
|
||||||
"localPath": "extensions/zalo",
|
"localPath": "extensions/zalo",
|
||||||
"defaultChoice": "npm"
|
"defaultChoice": "npm"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/zalouser",
|
"name": "@moltbot/zalouser",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
|
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -25,7 +25,7 @@
|
|||||||
"quickstartAllowFrom": true
|
"quickstartAllowFrom": true
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"npmSpec": "@clawdbot/zalouser",
|
"npmSpec": "@moltbot/zalouser",
|
||||||
"localPath": "extensions/zalouser",
|
"localPath": "extensions/zalouser",
|
||||||
"defaultChoice": "npm"
|
"defaultChoice": "npm"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "clawdbot",
|
"name": "moltbot",
|
||||||
"version": "2026.1.25",
|
"version": "2026.1.26",
|
||||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./dist/index.js",
|
".": "./dist/index.js",
|
||||||
"./plugin-sdk": "./dist/plugin-sdk/index.js",
|
"./plugin-sdk": "./dist/plugin-sdk/index.js",
|
||||||
"./plugin-sdk/*": "./dist/plugin-sdk/*"
|
"./plugin-sdk/*": "./dist/plugin-sdk/*",
|
||||||
|
"./cli-entry": "./dist/entry.js"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
"moltbot": "dist/entry.js",
|
||||||
"clawdbot": "dist/entry.js"
|
"clawdbot": "dist/entry.js"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
@ -90,6 +92,7 @@
|
|||||||
"ui:build": "node scripts/ui.js build",
|
"ui:build": "node scripts/ui.js build",
|
||||||
"start": "node scripts/run-node.mjs",
|
"start": "node scripts/run-node.mjs",
|
||||||
"clawdbot": "node scripts/run-node.mjs",
|
"clawdbot": "node scripts/run-node.mjs",
|
||||||
|
"moltbot": "node scripts/run-node.mjs",
|
||||||
"gateway:watch": "node scripts/watch-node.mjs gateway --force",
|
"gateway:watch": "node scripts/watch-node.mjs gateway --force",
|
||||||
"gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway",
|
"gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway",
|
||||||
"gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway --reset",
|
"gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway --reset",
|
||||||
|
|||||||
16
packages/clawdbot/package.json
Normal file
16
packages/clawdbot/package.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "clawdbot",
|
||||||
|
"version": "2026.1.26",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Compatibility shim that forwards to moltbot",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.js",
|
||||||
|
"./plugin-sdk": "./plugin-sdk/index.js"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"clawdbot": "./bin/clawdbot.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"moltbot": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -119,9 +119,16 @@ function mergeConfig(
|
|||||||
const provider = overrides?.provider ?? defaults?.provider ?? "auto";
|
const provider = overrides?.provider ?? defaults?.provider ?? "auto";
|
||||||
const defaultRemote = defaults?.remote;
|
const defaultRemote = defaults?.remote;
|
||||||
const overrideRemote = overrides?.remote;
|
const overrideRemote = overrides?.remote;
|
||||||
const hasRemote = Boolean(defaultRemote || overrideRemote);
|
const hasRemoteConfig = Boolean(
|
||||||
|
overrideRemote?.baseUrl ||
|
||||||
|
overrideRemote?.apiKey ||
|
||||||
|
overrideRemote?.headers ||
|
||||||
|
defaultRemote?.baseUrl ||
|
||||||
|
defaultRemote?.apiKey ||
|
||||||
|
defaultRemote?.headers,
|
||||||
|
);
|
||||||
const includeRemote =
|
const includeRemote =
|
||||||
hasRemote || provider === "openai" || provider === "gemini" || provider === "auto";
|
hasRemoteConfig || provider === "openai" || provider === "gemini" || provider === "auto";
|
||||||
const batch = {
|
const batch = {
|
||||||
enabled: overrideRemote?.batch?.enabled ?? defaultRemote?.batch?.enabled ?? true,
|
enabled: overrideRemote?.batch?.enabled ?? defaultRemote?.batch?.enabled ?? true,
|
||||||
wait: overrideRemote?.batch?.wait ?? defaultRemote?.batch?.wait ?? true,
|
wait: overrideRemote?.batch?.wait ?? defaultRemote?.batch?.wait ?? true,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import "./test-helpers/fast-coding-tools.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { ensureClawdbotModelsJson } from "./models-config.js";
|
import { ensureClawdbotModelsJson } from "./models-config.js";
|
||||||
|
|
||||||
|
|||||||
@ -215,6 +215,10 @@ export async function runEmbeddedAttempt(
|
|||||||
groupChannel: params.groupChannel,
|
groupChannel: params.groupChannel,
|
||||||
groupSpace: params.groupSpace,
|
groupSpace: params.groupSpace,
|
||||||
spawnedBy: params.spawnedBy,
|
spawnedBy: params.spawnedBy,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
sessionKey: params.sessionKey ?? params.sessionId,
|
sessionKey: params.sessionKey ?? params.sessionId,
|
||||||
agentDir,
|
agentDir,
|
||||||
workspaceDir: effectiveWorkspace,
|
workspaceDir: effectiveWorkspace,
|
||||||
|
|||||||
@ -35,6 +35,10 @@ export type RunEmbeddedPiAgentParams = {
|
|||||||
groupSpace?: string | null;
|
groupSpace?: string | null;
|
||||||
/** Parent session key for subagent policy inheritance. */
|
/** Parent session key for subagent policy inheritance. */
|
||||||
spawnedBy?: string | null;
|
spawnedBy?: string | null;
|
||||||
|
senderId?: string | null;
|
||||||
|
senderName?: string | null;
|
||||||
|
senderUsername?: string | null;
|
||||||
|
senderE164?: string | null;
|
||||||
/** Current channel ID for auto-threading (Slack). */
|
/** Current channel ID for auto-threading (Slack). */
|
||||||
currentChannelId?: string;
|
currentChannelId?: string;
|
||||||
/** Current thread timestamp for auto-threading (Slack). */
|
/** Current thread timestamp for auto-threading (Slack). */
|
||||||
|
|||||||
@ -31,6 +31,10 @@ export type EmbeddedRunAttemptParams = {
|
|||||||
groupSpace?: string | null;
|
groupSpace?: string | null;
|
||||||
/** Parent session key for subagent policy inheritance. */
|
/** Parent session key for subagent policy inheritance. */
|
||||||
spawnedBy?: string | null;
|
spawnedBy?: string | null;
|
||||||
|
senderId?: string | null;
|
||||||
|
senderName?: string | null;
|
||||||
|
senderUsername?: string | null;
|
||||||
|
senderE164?: string | null;
|
||||||
currentChannelId?: string;
|
currentChannelId?: string;
|
||||||
currentThreadTs?: string;
|
currentThreadTs?: string;
|
||||||
replyToMode?: "off" | "first" | "all";
|
replyToMode?: "off" | "first" | "all";
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import "./test-helpers/fast-coding-tools.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { createClawdbotCodingTools } from "./pi-tools.js";
|
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||||
import type { SandboxDockerConfig } from "./sandbox.js";
|
import type { SandboxDockerConfig } from "./sandbox.js";
|
||||||
@ -270,6 +271,75 @@ describe("Agent-specific tool filtering", () => {
|
|||||||
expect(defaultNames).not.toContain("exec");
|
expect(defaultNames).not.toContain("exec");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should apply per-sender tool policies for group tools", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
whatsapp: {
|
||||||
|
groups: {
|
||||||
|
"*": {
|
||||||
|
tools: { allow: ["read"] },
|
||||||
|
toolsBySender: {
|
||||||
|
alice: { allow: ["read", "exec"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const aliceTools = createClawdbotCodingTools({
|
||||||
|
config: cfg,
|
||||||
|
sessionKey: "agent:main:whatsapp:group:family",
|
||||||
|
senderId: "alice",
|
||||||
|
workspaceDir: "/tmp/test-group-sender",
|
||||||
|
agentDir: "/tmp/agent-group-sender",
|
||||||
|
});
|
||||||
|
const aliceNames = aliceTools.map((t) => t.name);
|
||||||
|
expect(aliceNames).toContain("read");
|
||||||
|
expect(aliceNames).toContain("exec");
|
||||||
|
|
||||||
|
const bobTools = createClawdbotCodingTools({
|
||||||
|
config: cfg,
|
||||||
|
sessionKey: "agent:main:whatsapp:group:family",
|
||||||
|
senderId: "bob",
|
||||||
|
workspaceDir: "/tmp/test-group-sender-bob",
|
||||||
|
agentDir: "/tmp/agent-group-sender",
|
||||||
|
});
|
||||||
|
const bobNames = bobTools.map((t) => t.name);
|
||||||
|
expect(bobNames).toContain("read");
|
||||||
|
expect(bobNames).not.toContain("exec");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not let default sender policy override group tools", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
whatsapp: {
|
||||||
|
groups: {
|
||||||
|
"*": {
|
||||||
|
toolsBySender: {
|
||||||
|
admin: { allow: ["read", "exec"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
locked: {
|
||||||
|
tools: { allow: ["read"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const adminTools = createClawdbotCodingTools({
|
||||||
|
config: cfg,
|
||||||
|
sessionKey: "agent:main:whatsapp:group:locked",
|
||||||
|
senderId: "admin",
|
||||||
|
workspaceDir: "/tmp/test-group-default-override",
|
||||||
|
agentDir: "/tmp/agent-group-default-override",
|
||||||
|
});
|
||||||
|
const adminNames = adminTools.map((t) => t.name);
|
||||||
|
expect(adminNames).toContain("read");
|
||||||
|
expect(adminNames).not.toContain("exec");
|
||||||
|
});
|
||||||
|
|
||||||
it("should resolve telegram group tool policy for topic session keys", () => {
|
it("should resolve telegram group tool policy for topic session keys", () => {
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
@ -233,6 +233,10 @@ export function resolveGroupToolPolicy(params: {
|
|||||||
groupChannel?: string | null;
|
groupChannel?: string | null;
|
||||||
groupSpace?: string | null;
|
groupSpace?: string | null;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
|
senderId?: string | null;
|
||||||
|
senderName?: string | null;
|
||||||
|
senderUsername?: string | null;
|
||||||
|
senderE164?: string | null;
|
||||||
}): SandboxToolPolicy | undefined {
|
}): SandboxToolPolicy | undefined {
|
||||||
if (!params.config) return undefined;
|
if (!params.config) return undefined;
|
||||||
const sessionContext = resolveGroupContextFromSessionKey(params.sessionKey);
|
const sessionContext = resolveGroupContextFromSessionKey(params.sessionKey);
|
||||||
@ -255,12 +259,20 @@ export function resolveGroupToolPolicy(params: {
|
|||||||
groupChannel: params.groupChannel,
|
groupChannel: params.groupChannel,
|
||||||
groupSpace: params.groupSpace,
|
groupSpace: params.groupSpace,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
}) ??
|
}) ??
|
||||||
resolveChannelGroupToolsPolicy({
|
resolveChannelGroupToolsPolicy({
|
||||||
cfg: params.config,
|
cfg: params.config,
|
||||||
channel,
|
channel,
|
||||||
groupId,
|
groupId,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
});
|
});
|
||||||
return pickToolPolicy(toolsConfig);
|
return pickToolPolicy(toolsConfig);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -140,6 +140,10 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
groupSpace?: string | null;
|
groupSpace?: string | null;
|
||||||
/** Parent session key for subagent group policy inheritance. */
|
/** Parent session key for subagent group policy inheritance. */
|
||||||
spawnedBy?: string | null;
|
spawnedBy?: string | null;
|
||||||
|
senderId?: string | null;
|
||||||
|
senderName?: string | null;
|
||||||
|
senderUsername?: string | null;
|
||||||
|
senderE164?: string | null;
|
||||||
/** Reply-to mode for Slack auto-threading. */
|
/** Reply-to mode for Slack auto-threading. */
|
||||||
replyToMode?: "off" | "first" | "all";
|
replyToMode?: "off" | "first" | "all";
|
||||||
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
||||||
@ -174,6 +178,10 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
groupChannel: options?.groupChannel,
|
groupChannel: options?.groupChannel,
|
||||||
groupSpace: options?.groupSpace,
|
groupSpace: options?.groupSpace,
|
||||||
accountId: options?.agentAccountId,
|
accountId: options?.agentAccountId,
|
||||||
|
senderId: options?.senderId,
|
||||||
|
senderName: options?.senderName,
|
||||||
|
senderUsername: options?.senderUsername,
|
||||||
|
senderE164: options?.senderE164,
|
||||||
});
|
});
|
||||||
const profilePolicy = resolveToolProfilePolicy(profile);
|
const profilePolicy = resolveToolProfilePolicy(profile);
|
||||||
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
|
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
|
||||||
|
|||||||
@ -8,12 +8,17 @@ const sendMessageTelegram = vi.fn(async () => ({
|
|||||||
messageId: "789",
|
messageId: "789",
|
||||||
chatId: "123",
|
chatId: "123",
|
||||||
}));
|
}));
|
||||||
|
const sendStickerTelegram = vi.fn(async () => ({
|
||||||
|
messageId: "456",
|
||||||
|
chatId: "123",
|
||||||
|
}));
|
||||||
const deleteMessageTelegram = vi.fn(async () => ({ ok: true }));
|
const deleteMessageTelegram = vi.fn(async () => ({ ok: true }));
|
||||||
const originalToken = process.env.TELEGRAM_BOT_TOKEN;
|
const originalToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
|
||||||
vi.mock("../../telegram/send.js", () => ({
|
vi.mock("../../telegram/send.js", () => ({
|
||||||
reactMessageTelegram: (...args: unknown[]) => reactMessageTelegram(...args),
|
reactMessageTelegram: (...args: unknown[]) => reactMessageTelegram(...args),
|
||||||
sendMessageTelegram: (...args: unknown[]) => sendMessageTelegram(...args),
|
sendMessageTelegram: (...args: unknown[]) => sendMessageTelegram(...args),
|
||||||
|
sendStickerTelegram: (...args: unknown[]) => sendStickerTelegram(...args),
|
||||||
deleteMessageTelegram: (...args: unknown[]) => deleteMessageTelegram(...args),
|
deleteMessageTelegram: (...args: unknown[]) => deleteMessageTelegram(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -21,6 +26,7 @@ describe("handleTelegramAction", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
reactMessageTelegram.mockClear();
|
reactMessageTelegram.mockClear();
|
||||||
sendMessageTelegram.mockClear();
|
sendMessageTelegram.mockClear();
|
||||||
|
sendStickerTelegram.mockClear();
|
||||||
deleteMessageTelegram.mockClear();
|
deleteMessageTelegram.mockClear();
|
||||||
process.env.TELEGRAM_BOT_TOKEN = "tok";
|
process.env.TELEGRAM_BOT_TOKEN = "tok";
|
||||||
});
|
});
|
||||||
@ -96,6 +102,40 @@ describe("handleTelegramAction", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects sticker actions when disabled by default", async () => {
|
||||||
|
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
|
||||||
|
await expect(
|
||||||
|
handleTelegramAction(
|
||||||
|
{
|
||||||
|
action: "sendSticker",
|
||||||
|
to: "123",
|
||||||
|
fileId: "sticker",
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/sticker actions are disabled/i);
|
||||||
|
expect(sendStickerTelegram).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends stickers when enabled", async () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: { telegram: { botToken: "tok", actions: { sticker: true } } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
await handleTelegramAction(
|
||||||
|
{
|
||||||
|
action: "sendSticker",
|
||||||
|
to: "123",
|
||||||
|
fileId: "sticker",
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
expect(sendStickerTelegram).toHaveBeenCalledWith(
|
||||||
|
"123",
|
||||||
|
"sticker",
|
||||||
|
expect.objectContaining({ token: "tok" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("removes reactions when remove flag set", async () => {
|
it("removes reactions when remove flag set", async () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } },
|
channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } },
|
||||||
|
|||||||
@ -6,7 +6,9 @@ import {
|
|||||||
editMessageTelegram,
|
editMessageTelegram,
|
||||||
reactMessageTelegram,
|
reactMessageTelegram,
|
||||||
sendMessageTelegram,
|
sendMessageTelegram,
|
||||||
|
sendStickerTelegram,
|
||||||
} from "../../telegram/send.js";
|
} from "../../telegram/send.js";
|
||||||
|
import { getCacheStats, searchStickers } from "../../telegram/sticker-cache.js";
|
||||||
import { resolveTelegramToken } from "../../telegram/token.js";
|
import { resolveTelegramToken } from "../../telegram/token.js";
|
||||||
import {
|
import {
|
||||||
resolveTelegramInlineButtonsScope,
|
resolveTelegramInlineButtonsScope,
|
||||||
@ -255,5 +257,64 @@ export async function handleTelegramAction(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === "sendSticker") {
|
||||||
|
if (!isActionEnabled("sticker", false)) {
|
||||||
|
throw new Error(
|
||||||
|
"Telegram sticker actions are disabled. Set channels.telegram.actions.sticker to true.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const to = readStringParam(params, "to", { required: true });
|
||||||
|
const fileId = readStringParam(params, "fileId", { required: true });
|
||||||
|
const replyToMessageId = readNumberParam(params, "replyToMessageId", {
|
||||||
|
integer: true,
|
||||||
|
});
|
||||||
|
const messageThreadId = readNumberParam(params, "messageThreadId", {
|
||||||
|
integer: true,
|
||||||
|
});
|
||||||
|
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||||
|
if (!token) {
|
||||||
|
throw new Error(
|
||||||
|
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = await sendStickerTelegram(to, fileId, {
|
||||||
|
token,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
replyToMessageId: replyToMessageId ?? undefined,
|
||||||
|
messageThreadId: messageThreadId ?? undefined,
|
||||||
|
});
|
||||||
|
return jsonResult({
|
||||||
|
ok: true,
|
||||||
|
messageId: result.messageId,
|
||||||
|
chatId: result.chatId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "searchSticker") {
|
||||||
|
if (!isActionEnabled("sticker", false)) {
|
||||||
|
throw new Error(
|
||||||
|
"Telegram sticker actions are disabled. Set channels.telegram.actions.sticker to true.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const query = readStringParam(params, "query", { required: true });
|
||||||
|
const limit = readNumberParam(params, "limit", { integer: true }) ?? 5;
|
||||||
|
const results = searchStickers(query, limit);
|
||||||
|
return jsonResult({
|
||||||
|
ok: true,
|
||||||
|
count: results.length,
|
||||||
|
stickers: results.map((s) => ({
|
||||||
|
fileId: s.fileId,
|
||||||
|
emoji: s.emoji,
|
||||||
|
description: s.description,
|
||||||
|
setName: s.setName,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "stickerCacheStats") {
|
||||||
|
const stats = getCacheStats();
|
||||||
|
return jsonResult({ ok: true, ...stats });
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(`Unsupported Telegram action: ${action}`);
|
throw new Error(`Unsupported Telegram action: ${action}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,11 @@ import { listChannelDocks } from "../channels/dock.js";
|
|||||||
import { getActivePluginRegistry } from "../plugins/runtime.js";
|
import { getActivePluginRegistry } from "../plugins/runtime.js";
|
||||||
import { listThinkingLevels } from "./thinking.js";
|
import { listThinkingLevels } from "./thinking.js";
|
||||||
import { COMMAND_ARG_FORMATTERS } from "./commands-args.js";
|
import { COMMAND_ARG_FORMATTERS } from "./commands-args.js";
|
||||||
import type { ChatCommandDefinition, CommandScope } from "./commands-registry.types.js";
|
import type {
|
||||||
|
ChatCommandDefinition,
|
||||||
|
CommandCategory,
|
||||||
|
CommandScope,
|
||||||
|
} from "./commands-registry.types.js";
|
||||||
|
|
||||||
type DefineChatCommandInput = {
|
type DefineChatCommandInput = {
|
||||||
key: string;
|
key: string;
|
||||||
@ -16,6 +20,7 @@ type DefineChatCommandInput = {
|
|||||||
textAlias?: string;
|
textAlias?: string;
|
||||||
textAliases?: string[];
|
textAliases?: string[];
|
||||||
scope?: CommandScope;
|
scope?: CommandScope;
|
||||||
|
category?: CommandCategory;
|
||||||
};
|
};
|
||||||
|
|
||||||
function defineChatCommand(command: DefineChatCommandInput): ChatCommandDefinition {
|
function defineChatCommand(command: DefineChatCommandInput): ChatCommandDefinition {
|
||||||
@ -37,6 +42,7 @@ function defineChatCommand(command: DefineChatCommandInput): ChatCommandDefiniti
|
|||||||
argsMenu: command.argsMenu,
|
argsMenu: command.argsMenu,
|
||||||
textAliases: aliases,
|
textAliases: aliases,
|
||||||
scope,
|
scope,
|
||||||
|
category: command.category,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,6 +54,7 @@ function defineDockCommand(dock: ChannelDock): ChatCommandDefinition {
|
|||||||
nativeName: `dock_${dock.id}`,
|
nativeName: `dock_${dock.id}`,
|
||||||
description: `Switch to ${dock.id} for replies.`,
|
description: `Switch to ${dock.id} for replies.`,
|
||||||
textAliases: [`/dock-${dock.id}`, `/dock_${dock.id}`],
|
textAliases: [`/dock-${dock.id}`, `/dock_${dock.id}`],
|
||||||
|
category: "docks",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,18 +131,21 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
nativeName: "help",
|
nativeName: "help",
|
||||||
description: "Show available commands.",
|
description: "Show available commands.",
|
||||||
textAlias: "/help",
|
textAlias: "/help",
|
||||||
|
category: "status",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "commands",
|
key: "commands",
|
||||||
nativeName: "commands",
|
nativeName: "commands",
|
||||||
description: "List all slash commands.",
|
description: "List all slash commands.",
|
||||||
textAlias: "/commands",
|
textAlias: "/commands",
|
||||||
|
category: "status",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "skill",
|
key: "skill",
|
||||||
nativeName: "skill",
|
nativeName: "skill",
|
||||||
description: "Run a skill by name.",
|
description: "Run a skill by name.",
|
||||||
textAlias: "/skill",
|
textAlias: "/skill",
|
||||||
|
category: "tools",
|
||||||
args: [
|
args: [
|
||||||
{
|
{
|
||||||
name: "name",
|
name: "name",
|
||||||
@ -156,6 +166,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
nativeName: "status",
|
nativeName: "status",
|
||||||
description: "Show current status.",
|
description: "Show current status.",
|
||||||
textAlias: "/status",
|
textAlias: "/status",
|
||||||
|
category: "status",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "allowlist",
|
key: "allowlist",
|
||||||
@ -163,6 +174,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
textAlias: "/allowlist",
|
textAlias: "/allowlist",
|
||||||
acceptsArgs: true,
|
acceptsArgs: true,
|
||||||
scope: "text",
|
scope: "text",
|
||||||
|
category: "management",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "approve",
|
key: "approve",
|
||||||
@ -170,6 +182,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
description: "Approve or deny exec requests.",
|
description: "Approve or deny exec requests.",
|
||||||
textAlias: "/approve",
|
textAlias: "/approve",
|
||||||
acceptsArgs: true,
|
acceptsArgs: true,
|
||||||
|
category: "management",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "context",
|
key: "context",
|
||||||
@ -177,12 +190,14 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
description: "Explain how context is built and used.",
|
description: "Explain how context is built and used.",
|
||||||
textAlias: "/context",
|
textAlias: "/context",
|
||||||
acceptsArgs: true,
|
acceptsArgs: true,
|
||||||
|
category: "status",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "tts",
|
key: "tts",
|
||||||
nativeName: "tts",
|
nativeName: "tts",
|
||||||
description: "Control text-to-speech (TTS).",
|
description: "Control text-to-speech (TTS).",
|
||||||
textAlias: "/tts",
|
textAlias: "/tts",
|
||||||
|
category: "media",
|
||||||
args: [
|
args: [
|
||||||
{
|
{
|
||||||
name: "action",
|
name: "action",
|
||||||
@ -225,12 +240,14 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
nativeName: "whoami",
|
nativeName: "whoami",
|
||||||
description: "Show your sender id.",
|
description: "Show your sender id.",
|
||||||
textAlias: "/whoami",
|
textAlias: "/whoami",
|
||||||
|
category: "status",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "subagents",
|
key: "subagents",
|
||||||
nativeName: "subagents",
|
nativeName: "subagents",
|
||||||
description: "List/stop/log/info subagent runs for this session.",
|
description: "List/stop/log/info subagent runs for this session.",
|
||||||
textAlias: "/subagents",
|
textAlias: "/subagents",
|
||||||
|
category: "management",
|
||||||
args: [
|
args: [
|
||||||
{
|
{
|
||||||
name: "action",
|
name: "action",
|
||||||
@ -257,6 +274,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
nativeName: "config",
|
nativeName: "config",
|
||||||
description: "Show or set config values.",
|
description: "Show or set config values.",
|
||||||
textAlias: "/config",
|
textAlias: "/config",
|
||||||
|
category: "management",
|
||||||
args: [
|
args: [
|
||||||
{
|
{
|
||||||
name: "action",
|
name: "action",
|
||||||
@ -284,6 +302,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
nativeName: "debug",
|
nativeName: "debug",
|
||||||
description: "Set runtime debug overrides.",
|
description: "Set runtime debug overrides.",
|
||||||
textAlias: "/debug",
|
textAlias: "/debug",
|
||||||
|
category: "management",
|
||||||
args: [
|
args: [
|
||||||
{
|
{
|
||||||
name: "action",
|
name: "action",
|
||||||
@ -311,6 +330,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
nativeName: "usage",
|
nativeName: "usage",
|
||||||
description: "Usage footer or cost summary.",
|
description: "Usage footer or cost summary.",
|
||||||
textAlias: "/usage",
|
textAlias: "/usage",
|
||||||
|
category: "options",
|
||||||
args: [
|
args: [
|
||||||
{
|
{
|
||||||
name: "mode",
|
name: "mode",
|
||||||
@ -326,18 +346,21 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
nativeName: "stop",
|
nativeName: "stop",
|
||||||
description: "Stop the current run.",
|
description: "Stop the current run.",
|
||||||
textAlias: "/stop",
|
textAlias: "/stop",
|
||||||
|
category: "session",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "restart",
|
key: "restart",
|
||||||
nativeName: "restart",
|
nativeName: "restart",
|
||||||
description: "Restart Clawdbot.",
|
description: "Restart Clawdbot.",
|
||||||
textAlias: "/restart",
|
textAlias: "/restart",
|
||||||
|
category: "tools",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "activation",
|
key: "activation",
|
||||||
nativeName: "activation",
|
nativeName: "activation",
|
||||||
description: "Set group activation mode.",
|
description: "Set group activation mode.",
|
||||||
textAlias: "/activation",
|
textAlias: "/activation",
|
||||||
|
category: "management",
|
||||||
args: [
|
args: [
|
||||||
{
|
{
|
||||||
name: "mode",
|
name: "mode",
|
||||||
@ -353,6 +376,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
nativeName: "send",
|
nativeName: "send",
|
||||||
description: "Set send policy.",
|
description: "Set send policy.",
|
||||||
textAlias: "/send",
|
textAlias: "/send",
|
||||||
|
category: "management",
|
||||||
args: [
|
args: [
|
||||||
{
|
{
|
||||||
name: "mode",
|
name: "mode",
|
||||||
@ -369,6 +393,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
description: "Reset the current session.",
|
description: "Reset the current session.",
|
||||||
textAlias: "/reset",
|
textAlias: "/reset",
|
||||||
acceptsArgs: true,
|
acceptsArgs: true,
|
||||||
|
category: "session",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "new",
|
key: "new",
|
||||||
@ -376,12 +401,14 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
description: "Start a new session.",
|
description: "Start a new session.",
|
||||||
textAlias: "/new",
|
textAlias: "/new",
|
||||||
acceptsArgs: true,
|
acceptsArgs: true,
|
||||||
|
category: "session",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "compact",
|
key: "compact",
|
||||||
description: "Compact the session context.",
|
description: "Compact the session context.",
|
||||||
textAlias: "/compact",
|
textAlias: "/compact",
|
||||||
scope: "text",
|
scope: "text",
|
||||||
|
category: "session",
|
||||||
args: [
|
args: [
|
||||||
{
|
{
|
||||||
name: "instructions",
|
name: "instructions",
|
||||||
@ -396,6 +423,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
nativeName: "think",
|
nativeName: "think",
|
||||||
description: "Set thinking level.",
|
description: "Set thinking level.",
|
||||||
textAlias: "/think",
|
textAlias: "/think",
|
||||||
|
category: "options",
|
||||||
args: [
|
args: [
|
||||||
{
|
{
|
||||||
name: "level",
|
name: "level",
|
||||||
@ -411,6 +439,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
nativeName: "verbose",
|
nativeName: "verbose",
|
||||||
description: "Toggle verbose mode.",
|
description: "Toggle verbose mode.",
|
||||||
textAlias: "/verbose",
|
textAlias: "/verbose",
|
||||||
|
category: "options",
|
||||||
args: [
|
args: [
|
||||||
{
|
{
|
||||||
name: "mode",
|
name: "mode",
|
||||||
@ -426,6 +455,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
nativeName: "reasoning",
|
nativeName: "reasoning",
|
||||||
description: "Toggle reasoning visibility.",
|
description: "Toggle reasoning visibility.",
|
||||||
textAlias: "/reasoning",
|
textAlias: "/reasoning",
|
||||||
|
category: "options",
|
||||||
args: [
|
args: [
|
||||||
{
|
{
|
||||||
name: "mode",
|
name: "mode",
|
||||||
@ -441,6 +471,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
nativeName: "elevated",
|
nativeName: "elevated",
|
||||||
description: "Toggle elevated mode.",
|
description: "Toggle elevated mode.",
|
||||||
textAlias: "/elevated",
|
textAlias: "/elevated",
|
||||||
|
category: "options",
|
||||||
args: [
|
args: [
|
||||||
{
|
{
|
||||||
name: "mode",
|
name: "mode",
|
||||||
@ -456,6 +487,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
nativeName: "exec",
|
nativeName: "exec",
|
||||||
description: "Set exec defaults for this session.",
|
description: "Set exec defaults for this session.",
|
||||||
textAlias: "/exec",
|
textAlias: "/exec",
|
||||||
|
category: "options",
|
||||||
args: [
|
args: [
|
||||||
{
|
{
|
||||||
name: "options",
|
name: "options",
|
||||||
@ -470,6 +502,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
nativeName: "model",
|
nativeName: "model",
|
||||||
description: "Show or set the model.",
|
description: "Show or set the model.",
|
||||||
textAlias: "/model",
|
textAlias: "/model",
|
||||||
|
category: "options",
|
||||||
args: [
|
args: [
|
||||||
{
|
{
|
||||||
name: "model",
|
name: "model",
|
||||||
@ -485,12 +518,14 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
textAlias: "/models",
|
textAlias: "/models",
|
||||||
argsParsing: "none",
|
argsParsing: "none",
|
||||||
acceptsArgs: true,
|
acceptsArgs: true,
|
||||||
|
category: "options",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "queue",
|
key: "queue",
|
||||||
nativeName: "queue",
|
nativeName: "queue",
|
||||||
description: "Adjust queue settings.",
|
description: "Adjust queue settings.",
|
||||||
textAlias: "/queue",
|
textAlias: "/queue",
|
||||||
|
category: "options",
|
||||||
args: [
|
args: [
|
||||||
{
|
{
|
||||||
name: "mode",
|
name: "mode",
|
||||||
@ -523,6 +558,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
description: "Run host shell commands (host-only).",
|
description: "Run host shell commands (host-only).",
|
||||||
textAlias: "/bash",
|
textAlias: "/bash",
|
||||||
scope: "text",
|
scope: "text",
|
||||||
|
category: "tools",
|
||||||
args: [
|
args: [
|
||||||
{
|
{
|
||||||
name: "command",
|
name: "command",
|
||||||
|
|||||||
@ -2,6 +2,15 @@ import type { ClawdbotConfig } from "../config/types.js";
|
|||||||
|
|
||||||
export type CommandScope = "text" | "native" | "both";
|
export type CommandScope = "text" | "native" | "both";
|
||||||
|
|
||||||
|
export type CommandCategory =
|
||||||
|
| "session"
|
||||||
|
| "options"
|
||||||
|
| "status"
|
||||||
|
| "management"
|
||||||
|
| "media"
|
||||||
|
| "tools"
|
||||||
|
| "docks";
|
||||||
|
|
||||||
export type CommandArgType = "string" | "number" | "boolean";
|
export type CommandArgType = "string" | "number" | "boolean";
|
||||||
|
|
||||||
export type CommandArgChoiceContext = {
|
export type CommandArgChoiceContext = {
|
||||||
@ -51,6 +60,7 @@ export type ChatCommandDefinition = {
|
|||||||
formatArgs?: (values: CommandArgValues) => string | undefined;
|
formatArgs?: (values: CommandArgValues) => string | undefined;
|
||||||
argsMenu?: CommandArgMenuSpec | "auto";
|
argsMenu?: CommandArgMenuSpec | "auto";
|
||||||
scope: CommandScope;
|
scope: CommandScope;
|
||||||
|
category?: CommandCategory;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NativeCommandSpec = {
|
export type NativeCommandSpec = {
|
||||||
|
|||||||
@ -232,6 +232,10 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
groupChannel:
|
groupChannel:
|
||||||
params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(),
|
params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(),
|
||||||
groupSpace: params.sessionCtx.GroupSpace?.trim() ?? undefined,
|
groupSpace: params.sessionCtx.GroupSpace?.trim() ?? undefined,
|
||||||
|
senderId: params.sessionCtx.SenderId?.trim() || undefined,
|
||||||
|
senderName: params.sessionCtx.SenderName?.trim() || undefined,
|
||||||
|
senderUsername: params.sessionCtx.SenderUsername?.trim() || undefined,
|
||||||
|
senderE164: params.sessionCtx.SenderE164?.trim() || undefined,
|
||||||
// Provider threading context for tool auto-injection
|
// Provider threading context for tool auto-injection
|
||||||
...buildThreadingToolContext({
|
...buildThreadingToolContext({
|
||||||
sessionCtx: params.sessionCtx,
|
sessionCtx: params.sessionCtx,
|
||||||
|
|||||||
@ -115,6 +115,10 @@ export async function runMemoryFlushIfNeeded(params: {
|
|||||||
config: params.followupRun.run.config,
|
config: params.followupRun.run.config,
|
||||||
hasRepliedRef: params.opts?.hasRepliedRef,
|
hasRepliedRef: params.opts?.hasRepliedRef,
|
||||||
}),
|
}),
|
||||||
|
senderId: params.sessionCtx.SenderId?.trim() || undefined,
|
||||||
|
senderName: params.sessionCtx.SenderName?.trim() || undefined,
|
||||||
|
senderUsername: params.sessionCtx.SenderUsername?.trim() || undefined,
|
||||||
|
senderE164: params.sessionCtx.SenderE164?.trim() || undefined,
|
||||||
sessionFile: params.followupRun.run.sessionFile,
|
sessionFile: params.followupRun.run.sessionFile,
|
||||||
workspaceDir: params.followupRun.run.workspaceDir,
|
workspaceDir: params.followupRun.run.workspaceDir,
|
||||||
agentDir: params.followupRun.run.agentDir,
|
agentDir: params.followupRun.run.agentDir,
|
||||||
|
|||||||
13
src/auto-reply/reply/commands-info.test.ts
Normal file
13
src/auto-reply/reply/commands-info.test.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildCommandsPaginationKeyboard } from "./commands-info.js";
|
||||||
|
|
||||||
|
describe("buildCommandsPaginationKeyboard", () => {
|
||||||
|
it("adds agent id to callback data when provided", () => {
|
||||||
|
const keyboard = buildCommandsPaginationKeyboard(2, 3, "agent-main");
|
||||||
|
expect(keyboard[0]).toEqual([
|
||||||
|
{ text: "◀ Prev", callback_data: "commands_page_1:agent-main" },
|
||||||
|
{ text: "2/3", callback_data: "commands_page_noop:agent-main" },
|
||||||
|
{ text: "Next ▶", callback_data: "commands_page_3:agent-main" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,10 @@
|
|||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
import { listSkillCommandsForWorkspace } from "../skill-commands.js";
|
import { listSkillCommandsForAgents } from "../skill-commands.js";
|
||||||
import { buildCommandsMessage, buildHelpMessage } from "../status.js";
|
import {
|
||||||
|
buildCommandsMessage,
|
||||||
|
buildCommandsMessagePaginated,
|
||||||
|
buildHelpMessage,
|
||||||
|
} from "../status.js";
|
||||||
import { buildStatusReply } from "./commands-status.js";
|
import { buildStatusReply } from "./commands-status.js";
|
||||||
import { buildContextReply } from "./commands-context-report.js";
|
import { buildContextReply } from "./commands-context-report.js";
|
||||||
import type { CommandHandler } from "./commands-types.js";
|
import type { CommandHandler } from "./commands-types.js";
|
||||||
@ -31,16 +35,78 @@ export const handleCommandsListCommand: CommandHandler = async (params, allowTex
|
|||||||
}
|
}
|
||||||
const skillCommands =
|
const skillCommands =
|
||||||
params.skillCommands ??
|
params.skillCommands ??
|
||||||
listSkillCommandsForWorkspace({
|
listSkillCommandsForAgents({
|
||||||
workspaceDir: params.workspaceDir,
|
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
|
agentIds: params.agentId ? [params.agentId] : undefined,
|
||||||
});
|
});
|
||||||
|
const surface = params.ctx.Surface;
|
||||||
|
|
||||||
|
if (surface === "telegram") {
|
||||||
|
const result = buildCommandsMessagePaginated(params.cfg, skillCommands, {
|
||||||
|
page: 1,
|
||||||
|
surface,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.totalPages > 1) {
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: {
|
||||||
|
text: result.text,
|
||||||
|
channelData: {
|
||||||
|
telegram: {
|
||||||
|
buttons: buildCommandsPaginationKeyboard(
|
||||||
|
result.currentPage,
|
||||||
|
result.totalPages,
|
||||||
|
params.agentId,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: result.text },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
shouldContinue: false,
|
shouldContinue: false,
|
||||||
reply: { text: buildCommandsMessage(params.cfg, skillCommands) },
|
reply: { text: buildCommandsMessage(params.cfg, skillCommands, { surface }) },
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function buildCommandsPaginationKeyboard(
|
||||||
|
currentPage: number,
|
||||||
|
totalPages: number,
|
||||||
|
agentId?: string,
|
||||||
|
): Array<Array<{ text: string; callback_data: string }>> {
|
||||||
|
const buttons: Array<{ text: string; callback_data: string }> = [];
|
||||||
|
const suffix = agentId ? `:${agentId}` : "";
|
||||||
|
|
||||||
|
if (currentPage > 1) {
|
||||||
|
buttons.push({
|
||||||
|
text: "◀ Prev",
|
||||||
|
callback_data: `commands_page_${currentPage - 1}${suffix}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
buttons.push({
|
||||||
|
text: `${currentPage}/${totalPages}`,
|
||||||
|
callback_data: `commands_page_noop${suffix}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
buttons.push({
|
||||||
|
text: "Next ▶",
|
||||||
|
callback_data: `commands_page_${currentPage + 1}${suffix}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [buttons];
|
||||||
|
}
|
||||||
|
|
||||||
export const handleStatusCommand: CommandHandler = async (params, allowTextCommands) => {
|
export const handleStatusCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||||
if (!allowTextCommands) return null;
|
if (!allowTextCommands) return null;
|
||||||
const statusRequested =
|
const statusRequested =
|
||||||
|
|||||||
@ -147,6 +147,10 @@ export function createFollowupRunner(params: {
|
|||||||
groupId: queued.run.groupId,
|
groupId: queued.run.groupId,
|
||||||
groupChannel: queued.run.groupChannel,
|
groupChannel: queued.run.groupChannel,
|
||||||
groupSpace: queued.run.groupSpace,
|
groupSpace: queued.run.groupSpace,
|
||||||
|
senderId: queued.run.senderId,
|
||||||
|
senderName: queued.run.senderName,
|
||||||
|
senderUsername: queued.run.senderUsername,
|
||||||
|
senderE164: queued.run.senderE164,
|
||||||
sessionFile: queued.run.sessionFile,
|
sessionFile: queued.run.sessionFile,
|
||||||
workspaceDir: queued.run.workspaceDir,
|
workspaceDir: queued.run.workspaceDir,
|
||||||
config: queued.run.config,
|
config: queued.run.config,
|
||||||
|
|||||||
@ -370,6 +370,10 @@ export async function runPreparedReply(
|
|||||||
groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined,
|
groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined,
|
||||||
groupChannel: sessionCtx.GroupChannel?.trim() ?? sessionCtx.GroupSubject?.trim(),
|
groupChannel: sessionCtx.GroupChannel?.trim() ?? sessionCtx.GroupSubject?.trim(),
|
||||||
groupSpace: sessionCtx.GroupSpace?.trim() ?? undefined,
|
groupSpace: sessionCtx.GroupSpace?.trim() ?? undefined,
|
||||||
|
senderId: sessionCtx.SenderId?.trim() || undefined,
|
||||||
|
senderName: sessionCtx.SenderName?.trim() || undefined,
|
||||||
|
senderUsername: sessionCtx.SenderUsername?.trim() || undefined,
|
||||||
|
senderE164: sessionCtx.SenderE164?.trim() || undefined,
|
||||||
sessionFile,
|
sessionFile,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
|||||||
@ -51,6 +51,10 @@ export type FollowupRun = {
|
|||||||
groupId?: string;
|
groupId?: string;
|
||||||
groupChannel?: string;
|
groupChannel?: string;
|
||||||
groupSpace?: string;
|
groupSpace?: string;
|
||||||
|
senderId?: string;
|
||||||
|
senderName?: string;
|
||||||
|
senderUsername?: string;
|
||||||
|
senderE164?: string;
|
||||||
sessionFile: string;
|
sessionFile: string;
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
config: ClawdbotConfig;
|
config: ClawdbotConfig;
|
||||||
|
|||||||
@ -4,7 +4,20 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
|
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
|
||||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { buildCommandsMessage, buildHelpMessage, buildStatusMessage } from "./status.js";
|
import {
|
||||||
|
buildCommandsMessage,
|
||||||
|
buildCommandsMessagePaginated,
|
||||||
|
buildHelpMessage,
|
||||||
|
buildStatusMessage,
|
||||||
|
} from "./status.js";
|
||||||
|
|
||||||
|
const { listPluginCommands } = vi.hoisted(() => ({
|
||||||
|
listPluginCommands: vi.fn(() => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../plugins/commands.js", () => ({
|
||||||
|
listPluginCommands,
|
||||||
|
}));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@ -400,10 +413,12 @@ describe("buildCommandsMessage", () => {
|
|||||||
const text = buildCommandsMessage({
|
const text = buildCommandsMessage({
|
||||||
commands: { config: false, debug: false },
|
commands: { config: false, debug: false },
|
||||||
} as ClawdbotConfig);
|
} as ClawdbotConfig);
|
||||||
|
expect(text).toContain("ℹ️ Slash commands");
|
||||||
|
expect(text).toContain("Status");
|
||||||
expect(text).toContain("/commands - List all slash commands.");
|
expect(text).toContain("/commands - List all slash commands.");
|
||||||
expect(text).toContain("/skill - Run a skill by name.");
|
expect(text).toContain("/skill - Run a skill by name.");
|
||||||
expect(text).toContain("/think (aliases: /thinking, /t) - Set thinking level.");
|
expect(text).toContain("/think (/thinking, /t) - Set thinking level.");
|
||||||
expect(text).toContain("/compact (text-only) - Compact the session context.");
|
expect(text).toContain("/compact [text] - Compact the session context.");
|
||||||
expect(text).not.toContain("/config");
|
expect(text).not.toContain("/config");
|
||||||
expect(text).not.toContain("/debug");
|
expect(text).not.toContain("/debug");
|
||||||
});
|
});
|
||||||
@ -430,8 +445,39 @@ describe("buildHelpMessage", () => {
|
|||||||
const text = buildHelpMessage({
|
const text = buildHelpMessage({
|
||||||
commands: { config: false, debug: false },
|
commands: { config: false, debug: false },
|
||||||
} as ClawdbotConfig);
|
} as ClawdbotConfig);
|
||||||
expect(text).toContain("Skills: /skill <name> [input]");
|
expect(text).toContain("Skills");
|
||||||
|
expect(text).toContain("/skill <name> [input]");
|
||||||
expect(text).not.toContain("/config");
|
expect(text).not.toContain("/config");
|
||||||
expect(text).not.toContain("/debug");
|
expect(text).not.toContain("/debug");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("buildCommandsMessagePaginated", () => {
|
||||||
|
it("formats telegram output with pages", () => {
|
||||||
|
const result = buildCommandsMessagePaginated(
|
||||||
|
{
|
||||||
|
commands: { config: false, debug: false },
|
||||||
|
} as ClawdbotConfig,
|
||||||
|
undefined,
|
||||||
|
{ surface: "telegram", page: 1 },
|
||||||
|
);
|
||||||
|
expect(result.text).toContain("ℹ️ Commands (1/");
|
||||||
|
expect(result.text).toContain("Session");
|
||||||
|
expect(result.text).toContain("/stop - Stop the current run.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes plugin commands in the paginated list", () => {
|
||||||
|
listPluginCommands.mockReturnValue([
|
||||||
|
{ name: "plugin_cmd", description: "Plugin command", pluginId: "demo-plugin" },
|
||||||
|
]);
|
||||||
|
const result = buildCommandsMessagePaginated(
|
||||||
|
{
|
||||||
|
commands: { config: false, debug: false },
|
||||||
|
} as ClawdbotConfig,
|
||||||
|
undefined,
|
||||||
|
{ surface: "telegram", page: 99 },
|
||||||
|
);
|
||||||
|
expect(result.text).toContain("Plugins");
|
||||||
|
expect(result.text).toContain("/plugin_cmd (demo-plugin) - Plugin command");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -29,9 +29,14 @@ import {
|
|||||||
resolveModelCostConfig,
|
resolveModelCostConfig,
|
||||||
} from "../utils/usage-format.js";
|
} from "../utils/usage-format.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
|
import {
|
||||||
|
listChatCommands,
|
||||||
|
listChatCommandsForConfig,
|
||||||
|
type ChatCommandDefinition,
|
||||||
|
} from "./commands-registry.js";
|
||||||
import { listPluginCommands } from "../plugins/commands.js";
|
import { listPluginCommands } from "../plugins/commands.js";
|
||||||
import type { SkillCommandSpec } from "../agents/skills.js";
|
import type { SkillCommandSpec } from "../agents/skills.js";
|
||||||
|
import type { CommandCategory } from "./commands-registry.types.js";
|
||||||
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
|
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
|
||||||
import type { MediaUnderstandingDecision } from "../media-understanding/types.js";
|
import type { MediaUnderstandingDecision } from "../media-understanding/types.js";
|
||||||
|
|
||||||
@ -427,61 +432,203 @@ export function buildStatusMessage(args: StatusArgs): string {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<CommandCategory, string> = {
|
||||||
|
session: "Session",
|
||||||
|
options: "Options",
|
||||||
|
status: "Status",
|
||||||
|
management: "Management",
|
||||||
|
media: "Media",
|
||||||
|
tools: "Tools",
|
||||||
|
docks: "Docks",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_ORDER: CommandCategory[] = [
|
||||||
|
"session",
|
||||||
|
"options",
|
||||||
|
"status",
|
||||||
|
"management",
|
||||||
|
"media",
|
||||||
|
"tools",
|
||||||
|
"docks",
|
||||||
|
];
|
||||||
|
|
||||||
|
function groupCommandsByCategory(
|
||||||
|
commands: ChatCommandDefinition[],
|
||||||
|
): Map<CommandCategory, ChatCommandDefinition[]> {
|
||||||
|
const grouped = new Map<CommandCategory, ChatCommandDefinition[]>();
|
||||||
|
for (const category of CATEGORY_ORDER) {
|
||||||
|
grouped.set(category, []);
|
||||||
|
}
|
||||||
|
for (const command of commands) {
|
||||||
|
const category = command.category ?? "tools";
|
||||||
|
const list = grouped.get(category) ?? [];
|
||||||
|
list.push(command);
|
||||||
|
grouped.set(category, list);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildHelpMessage(cfg?: ClawdbotConfig): string {
|
export function buildHelpMessage(cfg?: ClawdbotConfig): string {
|
||||||
const options = [
|
const lines = ["ℹ️ Help", ""];
|
||||||
"/think <level>",
|
|
||||||
"/verbose on|full|off",
|
lines.push("Session");
|
||||||
"/reasoning on|off",
|
lines.push(" /new | /reset | /compact [instructions] | /stop");
|
||||||
"/elevated on|off|ask|full",
|
lines.push("");
|
||||||
"/model <id>",
|
|
||||||
"/usage off|tokens|full",
|
const optionParts = ["/think <level>", "/model <id>", "/verbose on|off"];
|
||||||
];
|
if (cfg?.commands?.config === true) optionParts.push("/config");
|
||||||
if (cfg?.commands?.config === true) options.push("/config show");
|
if (cfg?.commands?.debug === true) optionParts.push("/debug");
|
||||||
if (cfg?.commands?.debug === true) options.push("/debug show");
|
lines.push("Options");
|
||||||
return [
|
lines.push(` ${optionParts.join(" | ")}`);
|
||||||
"ℹ️ Help",
|
lines.push("");
|
||||||
"Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)",
|
|
||||||
`Options: ${options.join(" | ")}`,
|
lines.push("Status");
|
||||||
"Skills: /skill <name> [input]",
|
lines.push(" /status | /whoami | /context");
|
||||||
"More: /commands for all slash commands",
|
lines.push("");
|
||||||
].join("\n");
|
|
||||||
|
lines.push("Skills");
|
||||||
|
lines.push(" /skill <name> [input]");
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push("More: /commands for full list");
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMANDS_PER_PAGE = 8;
|
||||||
|
|
||||||
|
export type CommandsMessageOptions = {
|
||||||
|
page?: number;
|
||||||
|
surface?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommandsMessageResult = {
|
||||||
|
text: string;
|
||||||
|
totalPages: number;
|
||||||
|
currentPage: number;
|
||||||
|
hasNext: boolean;
|
||||||
|
hasPrev: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatCommandEntry(command: ChatCommandDefinition): string {
|
||||||
|
const primary = command.nativeName
|
||||||
|
? `/${command.nativeName}`
|
||||||
|
: command.textAliases[0]?.trim() || `/${command.key}`;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const aliases = command.textAliases
|
||||||
|
.map((alias) => alias.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((alias) => alias.toLowerCase() !== primary.toLowerCase())
|
||||||
|
.filter((alias) => {
|
||||||
|
const key = alias.toLowerCase();
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const aliasLabel = aliases.length ? ` (${aliases.join(", ")})` : "";
|
||||||
|
const scopeLabel = command.scope === "text" ? " [text]" : "";
|
||||||
|
return `${primary}${aliasLabel}${scopeLabel} - ${command.description}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommandsListItem = {
|
||||||
|
label: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildCommandItems(
|
||||||
|
commands: ChatCommandDefinition[],
|
||||||
|
pluginCommands: ReturnType<typeof listPluginCommands>,
|
||||||
|
): CommandsListItem[] {
|
||||||
|
const grouped = groupCommandsByCategory(commands);
|
||||||
|
const items: CommandsListItem[] = [];
|
||||||
|
|
||||||
|
for (const category of CATEGORY_ORDER) {
|
||||||
|
const categoryCommands = grouped.get(category) ?? [];
|
||||||
|
if (categoryCommands.length === 0) continue;
|
||||||
|
const label = CATEGORY_LABELS[category];
|
||||||
|
for (const command of categoryCommands) {
|
||||||
|
items.push({ label, text: formatCommandEntry(command) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const command of pluginCommands) {
|
||||||
|
const pluginLabel = command.pluginId ? ` (${command.pluginId})` : "";
|
||||||
|
items.push({
|
||||||
|
label: "Plugins",
|
||||||
|
text: `/${command.name}${pluginLabel} - ${command.description}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCommandList(items: CommandsListItem[]): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
let currentLabel: string | null = null;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.label !== currentLabel) {
|
||||||
|
if (lines.length > 0) lines.push("");
|
||||||
|
lines.push(item.label);
|
||||||
|
currentLabel = item.label;
|
||||||
|
}
|
||||||
|
lines.push(` ${item.text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildCommandsMessage(
|
export function buildCommandsMessage(
|
||||||
cfg?: ClawdbotConfig,
|
cfg?: ClawdbotConfig,
|
||||||
skillCommands?: SkillCommandSpec[],
|
skillCommands?: SkillCommandSpec[],
|
||||||
|
options?: CommandsMessageOptions,
|
||||||
): string {
|
): string {
|
||||||
const lines = ["ℹ️ Slash commands"];
|
const result = buildCommandsMessagePaginated(cfg, skillCommands, options);
|
||||||
|
return result.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCommandsMessagePaginated(
|
||||||
|
cfg?: ClawdbotConfig,
|
||||||
|
skillCommands?: SkillCommandSpec[],
|
||||||
|
options?: CommandsMessageOptions,
|
||||||
|
): CommandsMessageResult {
|
||||||
|
const page = Math.max(1, options?.page ?? 1);
|
||||||
|
const surface = options?.surface?.toLowerCase();
|
||||||
|
const isTelegram = surface === "telegram";
|
||||||
|
|
||||||
const commands = cfg
|
const commands = cfg
|
||||||
? listChatCommandsForConfig(cfg, { skillCommands })
|
? listChatCommandsForConfig(cfg, { skillCommands })
|
||||||
: listChatCommands({ skillCommands });
|
: listChatCommands({ skillCommands });
|
||||||
for (const command of commands) {
|
|
||||||
const primary = command.nativeName
|
|
||||||
? `/${command.nativeName}`
|
|
||||||
: command.textAliases[0]?.trim() || `/${command.key}`;
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const aliases = command.textAliases
|
|
||||||
.map((alias) => alias.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.filter((alias) => alias.toLowerCase() !== primary.toLowerCase())
|
|
||||||
.filter((alias) => {
|
|
||||||
const key = alias.toLowerCase();
|
|
||||||
if (seen.has(key)) return false;
|
|
||||||
seen.add(key);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
const aliasLabel = aliases.length ? ` (aliases: ${aliases.join(", ")})` : "";
|
|
||||||
const scopeLabel = command.scope === "text" ? " (text-only)" : "";
|
|
||||||
lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`);
|
|
||||||
}
|
|
||||||
const pluginCommands = listPluginCommands();
|
const pluginCommands = listPluginCommands();
|
||||||
if (pluginCommands.length > 0) {
|
const items = buildCommandItems(commands, pluginCommands);
|
||||||
lines.push("");
|
|
||||||
lines.push("Plugin commands:");
|
if (!isTelegram) {
|
||||||
for (const command of pluginCommands) {
|
const lines = ["ℹ️ Slash commands", ""];
|
||||||
const pluginLabel = command.pluginId ? ` (plugin: ${command.pluginId})` : "";
|
lines.push(formatCommandList(items));
|
||||||
lines.push(`/${command.name}${pluginLabel} - ${command.description}`);
|
return {
|
||||||
}
|
text: lines.join("\n").trim(),
|
||||||
|
totalPages: 1,
|
||||||
|
currentPage: 1,
|
||||||
|
hasNext: false,
|
||||||
|
hasPrev: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return lines.join("\n");
|
|
||||||
|
const totalCommands = items.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCommands / COMMANDS_PER_PAGE));
|
||||||
|
const currentPage = Math.min(page, totalPages);
|
||||||
|
const startIndex = (currentPage - 1) * COMMANDS_PER_PAGE;
|
||||||
|
const endIndex = startIndex + COMMANDS_PER_PAGE;
|
||||||
|
const pageItems = items.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
const lines = [`ℹ️ Commands (${currentPage}/${totalPages})`, ""];
|
||||||
|
lines.push(formatCommandList(pageItems));
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: lines.join("\n").trim(),
|
||||||
|
totalPages,
|
||||||
|
currentPage,
|
||||||
|
hasNext: currentPage < totalPages,
|
||||||
|
hasPrev: currentPage > 1,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { ChannelId } from "../channels/plugins/types.js";
|
import type { ChannelId } from "../channels/plugins/types.js";
|
||||||
|
import type { StickerMetadata } from "../telegram/bot/types.js";
|
||||||
import type { InternalMessageChannel } from "../utils/message-channel.js";
|
import type { InternalMessageChannel } from "../utils/message-channel.js";
|
||||||
import type { CommandArgs } from "./commands-registry.types.js";
|
import type { CommandArgs } from "./commands-registry.types.js";
|
||||||
import type {
|
import type {
|
||||||
@ -64,6 +65,8 @@ export type MsgContext = {
|
|||||||
MediaPaths?: string[];
|
MediaPaths?: string[];
|
||||||
MediaUrls?: string[];
|
MediaUrls?: string[];
|
||||||
MediaTypes?: string[];
|
MediaTypes?: string[];
|
||||||
|
/** Telegram sticker metadata (emoji, set name, file IDs, cached description). */
|
||||||
|
Sticker?: StickerMetadata;
|
||||||
OutputDir?: string;
|
OutputDir?: string;
|
||||||
OutputBase?: string;
|
OutputBase?: string;
|
||||||
/** Remote host for SCP when media lives on a different machine (e.g., clawdbot@192.168.64.3). */
|
/** Remote host for SCP when media lives on a different machine (e.g., clawdbot@192.168.64.3). */
|
||||||
|
|||||||
@ -19,7 +19,8 @@ function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number):
|
|||||||
msgLower.includes("timed out") ||
|
msgLower.includes("timed out") ||
|
||||||
msgLower.includes("timeout") ||
|
msgLower.includes("timeout") ||
|
||||||
msgLower.includes("aborted") ||
|
msgLower.includes("aborted") ||
|
||||||
msgLower.includes("abort");
|
msgLower.includes("abort") ||
|
||||||
|
msgLower.includes("aborterror");
|
||||||
if (looksLikeTimeout) {
|
if (looksLikeTimeout) {
|
||||||
return new Error(
|
return new Error(
|
||||||
`Can't reach the clawd browser control service (timed out after ${timeoutMs}ms). ${hint}`,
|
`Can't reach the clawd browser control service (timed out after ${timeoutMs}ms). ${hint}`,
|
||||||
|
|||||||
@ -311,6 +311,9 @@ describe("backward compatibility (profile parameter)", () => {
|
|||||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||||
|
|
||||||
|
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||||
|
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||||
|
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(async (url: string) => {
|
vi.fn(async (url: string) => {
|
||||||
|
|||||||
@ -288,6 +288,9 @@ describe("profile CRUD endpoints", () => {
|
|||||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||||
|
|
||||||
|
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||||
|
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||||
|
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(async (url: string) => {
|
vi.fn(async (url: string) => {
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
6d63b866aa0e917b278c6bef42229e8cd1f43c8ba31c845a96b4d9d5ce780265
|
2567ca5bbc065b922d96717a488d5db3120b5b033c5d0508682d1aa8fbba470a
|
||||||
|
|||||||
@ -10,6 +10,13 @@ vi.mock("../../../agents/tools/telegram-actions.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("telegramMessageActions", () => {
|
describe("telegramMessageActions", () => {
|
||||||
|
it("excludes sticker actions when not enabled", () => {
|
||||||
|
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
|
||||||
|
const actions = telegramMessageActions.listActions({ cfg });
|
||||||
|
expect(actions).not.toContain("sticker");
|
||||||
|
expect(actions).not.toContain("sticker-search");
|
||||||
|
});
|
||||||
|
|
||||||
it("allows media-only sends and passes asVoice", async () => {
|
it("allows media-only sends and passes asVoice", async () => {
|
||||||
handleTelegramAction.mockClear();
|
handleTelegramAction.mockClear();
|
||||||
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
|
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
createActionGate,
|
createActionGate,
|
||||||
readNumberParam,
|
readNumberParam,
|
||||||
|
readStringArrayParam,
|
||||||
readStringOrNumberParam,
|
readStringOrNumberParam,
|
||||||
readStringParam,
|
readStringParam,
|
||||||
} from "../../../agents/tools/common.js";
|
} from "../../../agents/tools/common.js";
|
||||||
@ -45,6 +46,10 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
|||||||
if (gate("reactions")) actions.add("react");
|
if (gate("reactions")) actions.add("react");
|
||||||
if (gate("deleteMessage")) actions.add("delete");
|
if (gate("deleteMessage")) actions.add("delete");
|
||||||
if (gate("editMessage")) actions.add("edit");
|
if (gate("editMessage")) actions.add("edit");
|
||||||
|
if (gate("sticker", false)) {
|
||||||
|
actions.add("sticker");
|
||||||
|
actions.add("sticker-search");
|
||||||
|
}
|
||||||
return Array.from(actions);
|
return Array.from(actions);
|
||||||
},
|
},
|
||||||
supportsButtons: ({ cfg }) => {
|
supportsButtons: ({ cfg }) => {
|
||||||
@ -141,6 +146,41 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === "sticker") {
|
||||||
|
const to =
|
||||||
|
readStringParam(params, "to") ?? readStringParam(params, "target", { required: true });
|
||||||
|
// Accept stickerId (array from shared schema) and use first element as fileId
|
||||||
|
const stickerIds = readStringArrayParam(params, "stickerId");
|
||||||
|
const fileId = stickerIds?.[0] ?? readStringParam(params, "fileId", { required: true });
|
||||||
|
const replyToMessageId = readNumberParam(params, "replyTo", { integer: true });
|
||||||
|
const messageThreadId = readNumberParam(params, "threadId", { integer: true });
|
||||||
|
return await handleTelegramAction(
|
||||||
|
{
|
||||||
|
action: "sendSticker",
|
||||||
|
to,
|
||||||
|
fileId,
|
||||||
|
replyToMessageId: replyToMessageId ?? undefined,
|
||||||
|
messageThreadId: messageThreadId ?? undefined,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "sticker-search") {
|
||||||
|
const query = readStringParam(params, "query", { required: true });
|
||||||
|
const limit = readNumberParam(params, "limit", { integer: true });
|
||||||
|
return await handleTelegramAction(
|
||||||
|
{
|
||||||
|
action: "searchSticker",
|
||||||
|
query,
|
||||||
|
limit: limit ?? undefined,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,9 +2,13 @@ import type { ClawdbotConfig } from "../../config/config.js";
|
|||||||
import {
|
import {
|
||||||
resolveChannelGroupRequireMention,
|
resolveChannelGroupRequireMention,
|
||||||
resolveChannelGroupToolsPolicy,
|
resolveChannelGroupToolsPolicy,
|
||||||
|
resolveToolsBySender,
|
||||||
} from "../../config/group-policy.js";
|
} from "../../config/group-policy.js";
|
||||||
import type { DiscordConfig } from "../../config/types.js";
|
import type { DiscordConfig } from "../../config/types.js";
|
||||||
import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
|
import type {
|
||||||
|
GroupToolPolicyBySenderConfig,
|
||||||
|
GroupToolPolicyConfig,
|
||||||
|
} from "../../config/types.tools.js";
|
||||||
import { resolveSlackAccount } from "../../slack/accounts.js";
|
import { resolveSlackAccount } from "../../slack/accounts.js";
|
||||||
|
|
||||||
type GroupMentionParams = {
|
type GroupMentionParams = {
|
||||||
@ -13,6 +17,10 @@ type GroupMentionParams = {
|
|||||||
groupChannel?: string | null;
|
groupChannel?: string | null;
|
||||||
groupSpace?: string | null;
|
groupSpace?: string | null;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
|
senderId?: string | null;
|
||||||
|
senderName?: string | null;
|
||||||
|
senderUsername?: string | null;
|
||||||
|
senderE164?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeDiscordSlug(value?: string | null) {
|
function normalizeDiscordSlug(value?: string | null) {
|
||||||
@ -172,6 +180,10 @@ export function resolveGoogleChatGroupToolPolicy(
|
|||||||
channel: "googlechat",
|
channel: "googlechat",
|
||||||
groupId: params.groupId,
|
groupId: params.groupId,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -226,6 +238,10 @@ export function resolveTelegramGroupToolPolicy(
|
|||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
groupId: chatId ?? params.groupId,
|
groupId: chatId ?? params.groupId,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,6 +253,10 @@ export function resolveWhatsAppGroupToolPolicy(
|
|||||||
channel: "whatsapp",
|
channel: "whatsapp",
|
||||||
groupId: params.groupId,
|
groupId: params.groupId,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -248,6 +268,10 @@ export function resolveIMessageGroupToolPolicy(
|
|||||||
channel: "imessage",
|
channel: "imessage",
|
||||||
groupId: params.groupId,
|
groupId: params.groupId,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -268,8 +292,24 @@ export function resolveDiscordGroupToolPolicy(
|
|||||||
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
|
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
|
||||||
: undefined) ??
|
: undefined) ??
|
||||||
(groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined);
|
(groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined);
|
||||||
|
const senderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: entry?.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (senderPolicy) return senderPolicy;
|
||||||
if (entry?.tools) return entry.tools;
|
if (entry?.tools) return entry.tools;
|
||||||
}
|
}
|
||||||
|
const guildSenderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: guildEntry?.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (guildSenderPolicy) return guildSenderPolicy;
|
||||||
if (guildEntry?.tools) return guildEntry.tools;
|
if (guildEntry?.tools) return guildEntry.tools;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -294,7 +334,9 @@ export function resolveSlackGroupToolPolicy(
|
|||||||
channelName ?? "",
|
channelName ?? "",
|
||||||
normalizedName,
|
normalizedName,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
let matched: { tools?: GroupToolPolicyConfig } | undefined;
|
let matched:
|
||||||
|
| { tools?: GroupToolPolicyConfig; toolsBySender?: GroupToolPolicyBySenderConfig }
|
||||||
|
| undefined;
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (candidate && channels[candidate]) {
|
if (candidate && channels[candidate]) {
|
||||||
matched = channels[candidate];
|
matched = channels[candidate];
|
||||||
@ -302,6 +344,14 @@ export function resolveSlackGroupToolPolicy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const resolved = matched ?? channels["*"];
|
const resolved = matched ?? channels["*"];
|
||||||
|
const senderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: resolved?.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (senderPolicy) return senderPolicy;
|
||||||
if (resolved?.tools) return resolved.tools;
|
if (resolved?.tools) return resolved.tools;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -314,5 +364,9 @@ export function resolveBlueBubblesGroupToolPolicy(
|
|||||||
channel: "bluebubbles",
|
channel: "bluebubbles",
|
||||||
groupId: params.groupId,
|
groupId: params.groupId,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
|
|||||||
"thread-reply",
|
"thread-reply",
|
||||||
"search",
|
"search",
|
||||||
"sticker",
|
"sticker",
|
||||||
|
"sticker-search",
|
||||||
"member-info",
|
"member-info",
|
||||||
"role-info",
|
"role-info",
|
||||||
"emoji-list",
|
"emoji-list",
|
||||||
|
|||||||
@ -155,6 +155,10 @@ export type ChannelGroupContext = {
|
|||||||
groupChannel?: string | null;
|
groupChannel?: string | null;
|
||||||
groupSpace?: string | null;
|
groupSpace?: string | null;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
|
senderId?: string | null;
|
||||||
|
senderName?: string | null;
|
||||||
|
senderUsername?: string | null;
|
||||||
|
senderE164?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelCapabilities = {
|
export type ChannelCapabilities = {
|
||||||
|
|||||||
@ -1,17 +1,19 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
|
|
||||||
const clientMocks = vi.hoisted(() => ({
|
const gatewayMocks = vi.hoisted(() => ({
|
||||||
browserSnapshot: vi.fn(async () => ({
|
callGatewayFromCli: vi.fn(async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
format: "ai",
|
format: "ai",
|
||||||
targetId: "t1",
|
targetId: "t1",
|
||||||
url: "https://example.com",
|
url: "https://example.com",
|
||||||
snapshot: "ok",
|
snapshot: "ok",
|
||||||
})),
|
})),
|
||||||
resolveBrowserControlUrl: vi.fn(() => "http://127.0.0.1:18791"),
|
|
||||||
}));
|
}));
|
||||||
vi.mock("../browser/client.js", () => clientMocks);
|
|
||||||
|
vi.mock("./gateway-rpc.js", () => ({
|
||||||
|
callGatewayFromCli: gatewayMocks.callGatewayFromCli,
|
||||||
|
}));
|
||||||
|
|
||||||
const configMocks = vi.hoisted(() => ({
|
const configMocks = vi.hoisted(() => ({
|
||||||
loadConfig: vi.fn(() => ({ browser: {} })),
|
loadConfig: vi.fn(() => ({ browser: {} })),
|
||||||
@ -64,6 +66,7 @@ describe("browser cli snapshot defaults", () => {
|
|||||||
configMocks.loadConfig.mockReturnValue({
|
configMocks.loadConfig.mockReturnValue({
|
||||||
browser: { snapshotDefaults: { mode: "efficient" } },
|
browser: { snapshotDefaults: { mode: "efficient" } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
|
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
const browser = program.command("browser").option("--json", false);
|
const browser = program.command("browser").option("--json", false);
|
||||||
@ -84,13 +87,15 @@ describe("browser cli snapshot defaults", () => {
|
|||||||
configMocks.loadConfig.mockReturnValue({
|
configMocks.loadConfig.mockReturnValue({
|
||||||
browser: { snapshotDefaults: { mode: "efficient" } },
|
browser: { snapshotDefaults: { mode: "efficient" } },
|
||||||
});
|
});
|
||||||
clientMocks.browserSnapshot.mockResolvedValueOnce({
|
|
||||||
|
gatewayMocks.callGatewayFromCli.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
format: "aria",
|
format: "aria",
|
||||||
targetId: "t1",
|
targetId: "t1",
|
||||||
url: "https://example.com",
|
url: "https://example.com",
|
||||||
nodes: [],
|
nodes: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
|
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
const browser = program.command("browser").option("--json", false);
|
const browser = program.command("browser").option("--json", false);
|
||||||
|
|||||||
@ -1,4 +1,19 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
let previousProfile: string | undefined;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
previousProfile = process.env.CLAWDBOT_PROFILE;
|
||||||
|
process.env.CLAWDBOT_PROFILE = "isolated";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (previousProfile === undefined) {
|
||||||
|
delete process.env.CLAWDBOT_PROFILE;
|
||||||
|
} else {
|
||||||
|
process.env.CLAWDBOT_PROFILE = previousProfile;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
loadSessionStore: vi.fn().mockReturnValue({
|
loadSessionStore: vi.fn().mockReturnValue({
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
import type { ChannelId } from "../channels/plugins/types.js";
|
import type { ChannelId } from "../channels/plugins/types.js";
|
||||||
import { normalizeAccountId } from "../routing/session-key.js";
|
import { normalizeAccountId } from "../routing/session-key.js";
|
||||||
import type { ClawdbotConfig } from "./config.js";
|
import type { ClawdbotConfig } from "./config.js";
|
||||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
|
||||||
|
|
||||||
export type GroupPolicyChannel = ChannelId;
|
export type GroupPolicyChannel = ChannelId;
|
||||||
|
|
||||||
export type ChannelGroupConfig = {
|
export type ChannelGroupConfig = {
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
tools?: GroupToolPolicyConfig;
|
tools?: GroupToolPolicyConfig;
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelGroupPolicy = {
|
export type ChannelGroupPolicy = {
|
||||||
@ -19,6 +20,65 @@ export type ChannelGroupPolicy = {
|
|||||||
|
|
||||||
type ChannelGroups = Record<string, ChannelGroupConfig>;
|
type ChannelGroups = Record<string, ChannelGroupConfig>;
|
||||||
|
|
||||||
|
export type GroupToolPolicySender = {
|
||||||
|
senderId?: string | null;
|
||||||
|
senderName?: string | null;
|
||||||
|
senderUsername?: string | null;
|
||||||
|
senderE164?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeSenderKey(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
const withoutAt = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
||||||
|
return withoutAt.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveToolsBySender(
|
||||||
|
params: {
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
|
} & GroupToolPolicySender,
|
||||||
|
): GroupToolPolicyConfig | undefined {
|
||||||
|
const toolsBySender = params.toolsBySender;
|
||||||
|
if (!toolsBySender) return undefined;
|
||||||
|
const entries = Object.entries(toolsBySender);
|
||||||
|
if (entries.length === 0) return undefined;
|
||||||
|
|
||||||
|
const normalized = new Map<string, GroupToolPolicyConfig>();
|
||||||
|
let wildcard: GroupToolPolicyConfig | undefined;
|
||||||
|
for (const [rawKey, policy] of entries) {
|
||||||
|
if (!policy) continue;
|
||||||
|
const key = normalizeSenderKey(rawKey);
|
||||||
|
if (!key) continue;
|
||||||
|
if (key === "*") {
|
||||||
|
wildcard = policy;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!normalized.has(key)) {
|
||||||
|
normalized.set(key, policy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates: string[] = [];
|
||||||
|
const pushCandidate = (value?: string | null) => {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
candidates.push(trimmed);
|
||||||
|
};
|
||||||
|
pushCandidate(params.senderId);
|
||||||
|
pushCandidate(params.senderE164);
|
||||||
|
pushCandidate(params.senderUsername);
|
||||||
|
pushCandidate(params.senderName);
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const key = normalizeSenderKey(candidate);
|
||||||
|
if (!key) continue;
|
||||||
|
const match = normalized.get(key);
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
return wildcard;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveChannelGroups(
|
function resolveChannelGroups(
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
channel: GroupPolicyChannel,
|
channel: GroupPolicyChannel,
|
||||||
@ -94,14 +154,32 @@ export function resolveChannelGroupRequireMention(params: {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveChannelGroupToolsPolicy(params: {
|
export function resolveChannelGroupToolsPolicy(
|
||||||
cfg: ClawdbotConfig;
|
params: {
|
||||||
channel: GroupPolicyChannel;
|
cfg: ClawdbotConfig;
|
||||||
groupId?: string | null;
|
channel: GroupPolicyChannel;
|
||||||
accountId?: string | null;
|
groupId?: string | null;
|
||||||
}): GroupToolPolicyConfig | undefined {
|
accountId?: string | null;
|
||||||
|
} & GroupToolPolicySender,
|
||||||
|
): GroupToolPolicyConfig | undefined {
|
||||||
const { groupConfig, defaultConfig } = resolveChannelGroupPolicy(params);
|
const { groupConfig, defaultConfig } = resolveChannelGroupPolicy(params);
|
||||||
|
const groupSenderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: groupConfig?.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (groupSenderPolicy) return groupSenderPolicy;
|
||||||
if (groupConfig?.tools) return groupConfig.tools;
|
if (groupConfig?.tools) return groupConfig.tools;
|
||||||
|
const defaultSenderPolicy = resolveToolsBySender({
|
||||||
|
toolsBySender: defaultConfig?.toolsBySender,
|
||||||
|
senderId: params.senderId,
|
||||||
|
senderName: params.senderName,
|
||||||
|
senderUsername: params.senderUsername,
|
||||||
|
senderE164: params.senderE164,
|
||||||
|
});
|
||||||
|
if (defaultSenderPolicy) return defaultSenderPolicy;
|
||||||
if (defaultConfig?.tools) return defaultConfig.tools;
|
if (defaultConfig?.tools) return defaultConfig.tools;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import type {
|
|||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||||
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
||||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
|
||||||
|
|
||||||
export type DiscordDmConfig = {
|
export type DiscordDmConfig = {
|
||||||
/** If false, ignore all incoming Discord DMs. Default: true. */
|
/** If false, ignore all incoming Discord DMs. Default: true. */
|
||||||
@ -28,6 +28,7 @@ export type DiscordGuildChannelConfig = {
|
|||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
/** Optional tool policy overrides for this channel. */
|
/** Optional tool policy overrides for this channel. */
|
||||||
tools?: GroupToolPolicyConfig;
|
tools?: GroupToolPolicyConfig;
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
/** If specified, only load these skills for this channel. Omit = all skills; empty = no skills. */
|
/** If specified, only load these skills for this channel. Omit = all skills; empty = no skills. */
|
||||||
skills?: string[];
|
skills?: string[];
|
||||||
/** If false, disable the bot for this channel. */
|
/** If false, disable the bot for this channel. */
|
||||||
@ -45,6 +46,7 @@ export type DiscordGuildEntry = {
|
|||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
/** Optional tool policy overrides for this guild (used when channel override is missing). */
|
/** Optional tool policy overrides for this guild (used when channel override is missing). */
|
||||||
tools?: GroupToolPolicyConfig;
|
tools?: GroupToolPolicyConfig;
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
||||||
reactionNotifications?: DiscordReactionNotificationMode;
|
reactionNotifications?: DiscordReactionNotificationMode;
|
||||||
users?: Array<string | number>;
|
users?: Array<string | number>;
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import type {
|
|||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||||
import type { DmConfig } from "./types.messages.js";
|
import type { DmConfig } from "./types.messages.js";
|
||||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
|
||||||
|
|
||||||
export type IMessageAccountConfig = {
|
export type IMessageAccountConfig = {
|
||||||
/** Optional display name for this account (used in CLI/UI lists). */
|
/** Optional display name for this account (used in CLI/UI lists). */
|
||||||
@ -64,6 +64,7 @@ export type IMessageAccountConfig = {
|
|||||||
{
|
{
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
tools?: GroupToolPolicyConfig;
|
tools?: GroupToolPolicyConfig;
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
/** Heartbeat visibility settings for this channel. */
|
/** Heartbeat visibility settings for this channel. */
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import type {
|
|||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||||
import type { DmConfig } from "./types.messages.js";
|
import type { DmConfig } from "./types.messages.js";
|
||||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
|
||||||
|
|
||||||
export type MSTeamsWebhookConfig = {
|
export type MSTeamsWebhookConfig = {
|
||||||
/** Port for the webhook server. Default: 3978. */
|
/** Port for the webhook server. Default: 3978. */
|
||||||
@ -24,6 +24,7 @@ export type MSTeamsChannelConfig = {
|
|||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
/** Optional tool policy overrides for this channel. */
|
/** Optional tool policy overrides for this channel. */
|
||||||
tools?: GroupToolPolicyConfig;
|
tools?: GroupToolPolicyConfig;
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
/** Reply style: "thread" replies to the message, "top-level" posts a new message. */
|
/** Reply style: "thread" replies to the message, "top-level" posts a new message. */
|
||||||
replyStyle?: MSTeamsReplyStyle;
|
replyStyle?: MSTeamsReplyStyle;
|
||||||
};
|
};
|
||||||
@ -34,6 +35,7 @@ export type MSTeamsTeamConfig = {
|
|||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
/** Default tool policy for channels in this team. */
|
/** Default tool policy for channels in this team. */
|
||||||
tools?: GroupToolPolicyConfig;
|
tools?: GroupToolPolicyConfig;
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
/** Default reply style for channels in this team. */
|
/** Default reply style for channels in this team. */
|
||||||
replyStyle?: MSTeamsReplyStyle;
|
replyStyle?: MSTeamsReplyStyle;
|
||||||
/** Per-channel overrides. Key is conversation ID (e.g., "19:...@thread.tacv2"). */
|
/** Per-channel overrides. Key is conversation ID (e.g., "19:...@thread.tacv2"). */
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import type {
|
|||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||||
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
||||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
|
||||||
|
|
||||||
export type SlackDmConfig = {
|
export type SlackDmConfig = {
|
||||||
/** If false, ignore all incoming Slack DMs. Default: true. */
|
/** If false, ignore all incoming Slack DMs. Default: true. */
|
||||||
@ -33,6 +33,7 @@ export type SlackChannelConfig = {
|
|||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
/** Optional tool policy overrides for this channel. */
|
/** Optional tool policy overrides for this channel. */
|
||||||
tools?: GroupToolPolicyConfig;
|
tools?: GroupToolPolicyConfig;
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
/** Allow bot-authored messages to trigger replies (default: false). */
|
/** Allow bot-authored messages to trigger replies (default: false). */
|
||||||
allowBots?: boolean;
|
allowBots?: boolean;
|
||||||
/** Allowlist of users that can invoke the bot in this channel. */
|
/** Allowlist of users that can invoke the bot in this channel. */
|
||||||
|
|||||||
@ -9,13 +9,15 @@ import type {
|
|||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||||
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
||||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
|
||||||
|
|
||||||
export type TelegramActionConfig = {
|
export type TelegramActionConfig = {
|
||||||
reactions?: boolean;
|
reactions?: boolean;
|
||||||
sendMessage?: boolean;
|
sendMessage?: boolean;
|
||||||
deleteMessage?: boolean;
|
deleteMessage?: boolean;
|
||||||
editMessage?: boolean;
|
editMessage?: boolean;
|
||||||
|
/** Enable sticker actions (send and search). */
|
||||||
|
sticker?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TelegramNetworkConfig = {
|
export type TelegramNetworkConfig = {
|
||||||
@ -146,6 +148,7 @@ export type TelegramGroupConfig = {
|
|||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
/** Optional tool policy overrides for this group. */
|
/** Optional tool policy overrides for this group. */
|
||||||
tools?: GroupToolPolicyConfig;
|
tools?: GroupToolPolicyConfig;
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
/** If specified, only load these skills for this group (when no topic). Omit = all skills; empty = no skills. */
|
/** If specified, only load these skills for this group (when no topic). Omit = all skills; empty = no skills. */
|
||||||
skills?: string[];
|
skills?: string[];
|
||||||
/** Per-topic configuration (key is message_thread_id as string) */
|
/** Per-topic configuration (key is message_thread_id as string) */
|
||||||
|
|||||||
@ -158,6 +158,8 @@ export type GroupToolPolicyConfig = {
|
|||||||
deny?: string[];
|
deny?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GroupToolPolicyBySenderConfig = Record<string, GroupToolPolicyConfig>;
|
||||||
|
|
||||||
export type ExecToolConfig = {
|
export type ExecToolConfig = {
|
||||||
/** Exec host routing (default: sandbox). */
|
/** Exec host routing (default: sandbox). */
|
||||||
host?: "sandbox" | "gateway" | "node";
|
host?: "sandbox" | "gateway" | "node";
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import type {
|
|||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||||
import type { DmConfig } from "./types.messages.js";
|
import type { DmConfig } from "./types.messages.js";
|
||||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
|
||||||
|
|
||||||
export type WhatsAppActionConfig = {
|
export type WhatsAppActionConfig = {
|
||||||
reactions?: boolean;
|
reactions?: boolean;
|
||||||
@ -70,6 +70,7 @@ export type WhatsAppConfig = {
|
|||||||
{
|
{
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
tools?: GroupToolPolicyConfig;
|
tools?: GroupToolPolicyConfig;
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
/** Acknowledgment reaction sent immediately upon message receipt. */
|
/** Acknowledgment reaction sent immediately upon message receipt. */
|
||||||
@ -135,6 +136,7 @@ export type WhatsAppAccountConfig = {
|
|||||||
{
|
{
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
tools?: GroupToolPolicyConfig;
|
tools?: GroupToolPolicyConfig;
|
||||||
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
/** Acknowledgment reaction sent immediately upon message receipt. */
|
/** Acknowledgment reaction sent immediately upon message receipt. */
|
||||||
|
|||||||
@ -22,6 +22,8 @@ import {
|
|||||||
resolveTelegramCustomCommands,
|
resolveTelegramCustomCommands,
|
||||||
} from "./telegram-custom-commands.js";
|
} from "./telegram-custom-commands.js";
|
||||||
|
|
||||||
|
const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional();
|
||||||
|
|
||||||
const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "allowlist"]);
|
const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "allowlist"]);
|
||||||
|
|
||||||
const TelegramCapabilitiesSchema = z.union([
|
const TelegramCapabilitiesSchema = z.union([
|
||||||
@ -47,6 +49,7 @@ export const TelegramGroupSchema = z
|
|||||||
.object({
|
.object({
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
|
toolsBySender: ToolPolicyBySenderSchema,
|
||||||
skills: z.array(z.string()).optional(),
|
skills: z.array(z.string()).optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
@ -125,6 +128,7 @@ export const TelegramAccountSchemaBase = z
|
|||||||
reactions: z.boolean().optional(),
|
reactions: z.boolean().optional(),
|
||||||
sendMessage: z.boolean().optional(),
|
sendMessage: z.boolean().optional(),
|
||||||
deleteMessage: z.boolean().optional(),
|
deleteMessage: z.boolean().optional(),
|
||||||
|
sticker: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
@ -186,6 +190,7 @@ export const DiscordGuildChannelSchema = z
|
|||||||
allow: z.boolean().optional(),
|
allow: z.boolean().optional(),
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
|
toolsBySender: ToolPolicyBySenderSchema,
|
||||||
skills: z.array(z.string()).optional(),
|
skills: z.array(z.string()).optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
@ -199,6 +204,7 @@ export const DiscordGuildSchema = z
|
|||||||
slug: z.string().optional(),
|
slug: z.string().optional(),
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
|
toolsBySender: ToolPolicyBySenderSchema,
|
||||||
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
||||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
channels: z.record(z.string(), DiscordGuildChannelSchema.optional()).optional(),
|
channels: z.record(z.string(), DiscordGuildChannelSchema.optional()).optional(),
|
||||||
@ -374,6 +380,7 @@ export const SlackChannelSchema = z
|
|||||||
allow: z.boolean().optional(),
|
allow: z.boolean().optional(),
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
|
toolsBySender: ToolPolicyBySenderSchema,
|
||||||
allowBots: z.boolean().optional(),
|
allowBots: z.boolean().optional(),
|
||||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
skills: z.array(z.string()).optional(),
|
skills: z.array(z.string()).optional(),
|
||||||
@ -584,6 +591,7 @@ export const IMessageAccountSchemaBase = z
|
|||||||
.object({
|
.object({
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
|
toolsBySender: ToolPolicyBySenderSchema,
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
@ -640,6 +648,7 @@ const BlueBubblesGroupConfigSchema = z
|
|||||||
.object({
|
.object({
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
|
toolsBySender: ToolPolicyBySenderSchema,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@ -699,6 +708,7 @@ export const MSTeamsChannelSchema = z
|
|||||||
.object({
|
.object({
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
|
toolsBySender: ToolPolicyBySenderSchema,
|
||||||
replyStyle: MSTeamsReplyStyleSchema.optional(),
|
replyStyle: MSTeamsReplyStyleSchema.optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
@ -707,6 +717,7 @@ export const MSTeamsTeamSchema = z
|
|||||||
.object({
|
.object({
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
|
toolsBySender: ToolPolicyBySenderSchema,
|
||||||
replyStyle: MSTeamsReplyStyleSchema.optional(),
|
replyStyle: MSTeamsReplyStyleSchema.optional(),
|
||||||
channels: z.record(z.string(), MSTeamsChannelSchema.optional()).optional(),
|
channels: z.record(z.string(), MSTeamsChannelSchema.optional()).optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import {
|
|||||||
import { ToolPolicySchema } from "./zod-schema.agent-runtime.js";
|
import { ToolPolicySchema } from "./zod-schema.agent-runtime.js";
|
||||||
import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
|
import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
|
||||||
|
|
||||||
|
const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional();
|
||||||
|
|
||||||
export const WhatsAppAccountSchema = z
|
export const WhatsAppAccountSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
@ -41,6 +43,7 @@ export const WhatsAppAccountSchema = z
|
|||||||
.object({
|
.object({
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
|
toolsBySender: ToolPolicyBySenderSchema,
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
@ -105,6 +108,7 @@ export const WhatsAppConfigSchema = z
|
|||||||
.object({
|
.object({
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
|
toolsBySender: ToolPolicyBySenderSchema,
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@ -30,6 +30,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
|
|||||||
"thread-reply": "to",
|
"thread-reply": "to",
|
||||||
search: "none",
|
search: "none",
|
||||||
sticker: "to",
|
sticker: "to",
|
||||||
|
"sticker-search": "none",
|
||||||
"member-info": "none",
|
"member-info": "none",
|
||||||
"role-info": "none",
|
"role-info": "none",
|
||||||
"emoji-list": "none",
|
"emoji-list": "none",
|
||||||
|
|||||||
@ -412,6 +412,39 @@ async function resolveAutoEntries(params: {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveAutoImageModel(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
agentDir?: string;
|
||||||
|
activeModel?: ActiveMediaModel;
|
||||||
|
}): Promise<ActiveMediaModel | null> {
|
||||||
|
const providerRegistry = buildProviderRegistry();
|
||||||
|
const toActive = (entry: MediaUnderstandingModelConfig | null): ActiveMediaModel | null => {
|
||||||
|
if (!entry || entry.type === "cli") return null;
|
||||||
|
const provider = entry.provider;
|
||||||
|
if (!provider) return null;
|
||||||
|
const model = entry.model ?? DEFAULT_IMAGE_MODELS[provider];
|
||||||
|
if (!model) return null;
|
||||||
|
return { provider, model };
|
||||||
|
};
|
||||||
|
const activeEntry = await resolveActiveModelEntry({
|
||||||
|
cfg: params.cfg,
|
||||||
|
agentDir: params.agentDir,
|
||||||
|
providerRegistry,
|
||||||
|
capability: "image",
|
||||||
|
activeModel: params.activeModel,
|
||||||
|
});
|
||||||
|
const resolvedActive = toActive(activeEntry);
|
||||||
|
if (resolvedActive) return resolvedActive;
|
||||||
|
const keyEntry = await resolveKeyEntry({
|
||||||
|
cfg: params.cfg,
|
||||||
|
agentDir: params.agentDir,
|
||||||
|
providerRegistry,
|
||||||
|
capability: "image",
|
||||||
|
activeModel: params.activeModel,
|
||||||
|
});
|
||||||
|
return toActive(keyEntry);
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveActiveModelEntry(params: {
|
async function resolveActiveModelEntry(params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { DEFAULT_GEMINI_EMBEDDING_MODEL } from "./embeddings-gemini.js";
|
||||||
|
|
||||||
vi.mock("../agents/model-auth.js", () => ({
|
vi.mock("../agents/model-auth.js", () => ({
|
||||||
resolveApiKeyForProvider: vi.fn(),
|
resolveApiKeyForProvider: vi.fn(),
|
||||||
requireApiKey: (auth: { apiKey?: string; mode?: string }, provider: string) => {
|
requireApiKey: (auth: { apiKey?: string; mode?: string }, provider: string) => {
|
||||||
@ -193,6 +195,13 @@ describe("embedding provider auto selection", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses gemini when openai is missing", async () => {
|
it("uses gemini when openai is missing", async () => {
|
||||||
|
const fetchMock = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ embedding: { values: [1, 2, 3] } }),
|
||||||
|
})) as unknown as typeof fetch;
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
const { createEmbeddingProvider } = await import("./embeddings.js");
|
const { createEmbeddingProvider } = await import("./embeddings.js");
|
||||||
const authModule = await import("../agents/model-auth.js");
|
const authModule = await import("../agents/model-auth.js");
|
||||||
vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => {
|
vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => {
|
||||||
@ -214,6 +223,44 @@ describe("embedding provider auto selection", () => {
|
|||||||
|
|
||||||
expect(result.requestedProvider).toBe("auto");
|
expect(result.requestedProvider).toBe("auto");
|
||||||
expect(result.provider.id).toBe("gemini");
|
expect(result.provider.id).toBe("gemini");
|
||||||
|
await result.provider.embedQuery("hello");
|
||||||
|
const [url] = fetchMock.mock.calls[0] ?? [];
|
||||||
|
expect(url).toBe(
|
||||||
|
`https://generativelanguage.googleapis.com/v1beta/models/${DEFAULT_GEMINI_EMBEDDING_MODEL}:embedContent`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps explicit model when openai is selected", async () => {
|
||||||
|
const fetchMock = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ data: [{ embedding: [1, 2, 3] }] }),
|
||||||
|
})) as unknown as typeof fetch;
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const { createEmbeddingProvider } = await import("./embeddings.js");
|
||||||
|
const authModule = await import("../agents/model-auth.js");
|
||||||
|
vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => {
|
||||||
|
if (provider === "openai") {
|
||||||
|
return { apiKey: "openai-key", source: "env: OPENAI_API_KEY", mode: "api-key" };
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected provider ${provider}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await createEmbeddingProvider({
|
||||||
|
config: {} as never,
|
||||||
|
provider: "auto",
|
||||||
|
model: "text-embedding-3-small",
|
||||||
|
fallback: "none",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.requestedProvider).toBe("auto");
|
||||||
|
expect(result.provider.id).toBe("openai");
|
||||||
|
await result.provider.embedQuery("hello");
|
||||||
|
const [url, init] = fetchMock.mock.calls[0] ?? [];
|
||||||
|
expect(url).toBe("https://api.openai.com/v1/embeddings");
|
||||||
|
const payload = JSON.parse(String(init?.body ?? "{}")) as { model?: string };
|
||||||
|
expect(payload.model).toBe("text-embedding-3-small");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -81,6 +81,7 @@ export type {
|
|||||||
DmConfig,
|
DmConfig,
|
||||||
GroupPolicy,
|
GroupPolicy,
|
||||||
GroupToolPolicyConfig,
|
GroupToolPolicyConfig,
|
||||||
|
GroupToolPolicyBySenderConfig,
|
||||||
MarkdownConfig,
|
MarkdownConfig,
|
||||||
MarkdownTableMode,
|
MarkdownTableMode,
|
||||||
GoogleChatAccountConfig,
|
GoogleChatAccountConfig,
|
||||||
@ -121,6 +122,7 @@ export { resolveAckReaction } from "../agents/identity.js";
|
|||||||
export type { ReplyPayload } from "../auto-reply/types.js";
|
export type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
export type { ChunkMode } from "../auto-reply/chunk.js";
|
export type { ChunkMode } from "../auto-reply/chunk.js";
|
||||||
export { SILENT_REPLY_TOKEN, isSilentReplyText } from "../auto-reply/tokens.js";
|
export { SILENT_REPLY_TOKEN, isSilentReplyText } from "../auto-reply/tokens.js";
|
||||||
|
export { resolveToolsBySender } from "../config/group-policy.js";
|
||||||
export {
|
export {
|
||||||
buildPendingHistoryContextFromMap,
|
buildPendingHistoryContextFromMap,
|
||||||
clearHistoryEntries,
|
clearHistoryEntries,
|
||||||
|
|||||||
@ -4,6 +4,10 @@ import {
|
|||||||
createInboundDebouncer,
|
createInboundDebouncer,
|
||||||
resolveInboundDebounceMs,
|
resolveInboundDebounceMs,
|
||||||
} from "../auto-reply/inbound-debounce.js";
|
} from "../auto-reply/inbound-debounce.js";
|
||||||
|
import { buildCommandsPaginationKeyboard } from "../auto-reply/reply/commands-info.js";
|
||||||
|
import { buildCommandsMessagePaginated } from "../auto-reply/status.js";
|
||||||
|
import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js";
|
||||||
|
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { writeConfigFile } from "../config/io.js";
|
import { writeConfigFile } from "../config/io.js";
|
||||||
import { danger, logVerbose, warn } from "../globals.js";
|
import { danger, logVerbose, warn } from "../globals.js";
|
||||||
@ -17,6 +21,7 @@ import { migrateTelegramGroupConfig } from "./group-migration.js";
|
|||||||
import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js";
|
import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js";
|
||||||
import { readTelegramAllowFromStore } from "./pairing-store.js";
|
import { readTelegramAllowFromStore } from "./pairing-store.js";
|
||||||
import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js";
|
import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js";
|
||||||
|
import { buildInlineKeyboard } from "./send.js";
|
||||||
|
|
||||||
export const registerTelegramHandlers = ({
|
export const registerTelegramHandlers = ({
|
||||||
cfg,
|
cfg,
|
||||||
@ -112,11 +117,19 @@ export const registerTelegramHandlers = ({
|
|||||||
const captionMsg = entry.messages.find((m) => m.msg.caption || m.msg.text);
|
const captionMsg = entry.messages.find((m) => m.msg.caption || m.msg.text);
|
||||||
const primaryEntry = captionMsg ?? entry.messages[0];
|
const primaryEntry = captionMsg ?? entry.messages[0];
|
||||||
|
|
||||||
const allMedia: Array<{ path: string; contentType?: string }> = [];
|
const allMedia: Array<{
|
||||||
|
path: string;
|
||||||
|
contentType?: string;
|
||||||
|
stickerMetadata?: { emoji?: string; setName?: string; fileId?: string };
|
||||||
|
}> = [];
|
||||||
for (const { ctx } of entry.messages) {
|
for (const { ctx } of entry.messages) {
|
||||||
const media = await resolveMedia(ctx, mediaMaxBytes, opts.token, opts.proxyFetch);
|
const media = await resolveMedia(ctx, mediaMaxBytes, opts.token, opts.proxyFetch);
|
||||||
if (media) {
|
if (media) {
|
||||||
allMedia.push({ path: media.path, contentType: media.contentType });
|
allMedia.push({
|
||||||
|
path: media.path,
|
||||||
|
contentType: media.contentType,
|
||||||
|
stickerMetadata: media.stickerMetadata,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -315,6 +328,47 @@ export const registerTelegramHandlers = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const paginationMatch = data.match(/^commands_page_(\d+|noop)(?::(.+))?$/);
|
||||||
|
if (paginationMatch) {
|
||||||
|
const pageValue = paginationMatch[1];
|
||||||
|
if (pageValue === "noop") return;
|
||||||
|
|
||||||
|
const page = Number.parseInt(pageValue, 10);
|
||||||
|
if (Number.isNaN(page) || page < 1) return;
|
||||||
|
|
||||||
|
const agentId = paginationMatch[2]?.trim() || resolveDefaultAgentId(cfg) || undefined;
|
||||||
|
const skillCommands = listSkillCommandsForAgents({
|
||||||
|
cfg,
|
||||||
|
agentIds: agentId ? [agentId] : undefined,
|
||||||
|
});
|
||||||
|
const result = buildCommandsMessagePaginated(cfg, skillCommands, {
|
||||||
|
page,
|
||||||
|
surface: "telegram",
|
||||||
|
});
|
||||||
|
|
||||||
|
const keyboard =
|
||||||
|
result.totalPages > 1
|
||||||
|
? buildInlineKeyboard(
|
||||||
|
buildCommandsPaginationKeyboard(result.currentPage, result.totalPages, agentId),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await bot.api.editMessageText(
|
||||||
|
callbackMessage.chat.id,
|
||||||
|
callbackMessage.message_id,
|
||||||
|
result.text,
|
||||||
|
keyboard ? { reply_markup: keyboard } : undefined,
|
||||||
|
);
|
||||||
|
} catch (editErr) {
|
||||||
|
const errStr = String(editErr);
|
||||||
|
if (!errStr.includes("message is not modified")) {
|
||||||
|
throw editErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const syntheticMessage: TelegramMessage = {
|
const syntheticMessage: TelegramMessage = {
|
||||||
...callbackMessage,
|
...callbackMessage,
|
||||||
from: callback.from,
|
from: callback.from,
|
||||||
@ -595,7 +649,24 @@ export const registerTelegramHandlers = ({
|
|||||||
}
|
}
|
||||||
throw mediaErr;
|
throw mediaErr;
|
||||||
}
|
}
|
||||||
const allMedia = media ? [{ path: media.path, contentType: media.contentType }] : [];
|
|
||||||
|
// Skip sticker-only messages where the sticker was skipped (animated/video)
|
||||||
|
// These have no media and no text content to process.
|
||||||
|
const hasText = Boolean((msg.text ?? msg.caption ?? "").trim());
|
||||||
|
if (msg.sticker && !media && !hasText) {
|
||||||
|
logVerbose("telegram: skipping sticker-only message (unsupported sticker type)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allMedia = media
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
path: media.path,
|
||||||
|
contentType: media.contentType,
|
||||||
|
stickerMetadata: media.stickerMetadata,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
const senderId = msg.from?.id ? String(msg.from.id) : "";
|
const senderId = msg.from?.id ? String(msg.from.id) : "";
|
||||||
const conversationKey =
|
const conversationKey =
|
||||||
resolvedThreadId != null ? `${chatId}:topic:${resolvedThreadId}` : String(chatId);
|
resolvedThreadId != null ? `${chatId}:topic:${resolvedThreadId}` : String(chatId);
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
import type { Bot } from "grammy";
|
import type { Bot } from "grammy";
|
||||||
|
|
||||||
import { resolveAckReaction } from "../agents/identity.js";
|
import { resolveAckReaction } from "../agents/identity.js";
|
||||||
|
import {
|
||||||
|
findModelInCatalog,
|
||||||
|
loadModelCatalog,
|
||||||
|
modelSupportsVision,
|
||||||
|
} from "../agents/model-catalog.js";
|
||||||
|
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
|
||||||
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||||
import { normalizeCommandBody } from "../auto-reply/commands-registry.js";
|
import { normalizeCommandBody } from "../auto-reply/commands-registry.js";
|
||||||
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js";
|
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js";
|
||||||
@ -49,7 +55,17 @@ import {
|
|||||||
import { upsertTelegramPairingRequest } from "./pairing-store.js";
|
import { upsertTelegramPairingRequest } from "./pairing-store.js";
|
||||||
import type { TelegramContext } from "./bot/types.js";
|
import type { TelegramContext } from "./bot/types.js";
|
||||||
|
|
||||||
type TelegramMediaRef = { path: string; contentType?: string };
|
type TelegramMediaRef = {
|
||||||
|
path: string;
|
||||||
|
contentType?: string;
|
||||||
|
stickerMetadata?: {
|
||||||
|
emoji?: string;
|
||||||
|
setName?: string;
|
||||||
|
fileId?: string;
|
||||||
|
fileUniqueId?: string;
|
||||||
|
cachedDescription?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
type TelegramMessageContextOptions = {
|
type TelegramMessageContextOptions = {
|
||||||
forceWasMentioned?: boolean;
|
forceWasMentioned?: boolean;
|
||||||
@ -94,6 +110,24 @@ type BuildTelegramMessageContextParams = {
|
|||||||
resolveTelegramGroupConfig: ResolveTelegramGroupConfig;
|
resolveTelegramGroupConfig: ResolveTelegramGroupConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function resolveStickerVisionSupport(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
agentId?: string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const catalog = await loadModelCatalog({ config: params.cfg });
|
||||||
|
const defaultModel = resolveDefaultModelForAgent({
|
||||||
|
cfg: params.cfg,
|
||||||
|
agentId: params.agentId,
|
||||||
|
});
|
||||||
|
const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model);
|
||||||
|
if (!entry) return false;
|
||||||
|
return modelSupportsVision(entry);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const buildTelegramMessageContext = async ({
|
export const buildTelegramMessageContext = async ({
|
||||||
primaryCtx,
|
primaryCtx,
|
||||||
allMedia,
|
allMedia,
|
||||||
@ -302,6 +336,21 @@ export const buildTelegramMessageContext = async ({
|
|||||||
else if (msg.video) placeholder = "<media:video>";
|
else if (msg.video) placeholder = "<media:video>";
|
||||||
else if (msg.audio || msg.voice) placeholder = "<media:audio>";
|
else if (msg.audio || msg.voice) placeholder = "<media:audio>";
|
||||||
else if (msg.document) placeholder = "<media:document>";
|
else if (msg.document) placeholder = "<media:document>";
|
||||||
|
else if (msg.sticker) placeholder = "<media:sticker>";
|
||||||
|
|
||||||
|
// Check if sticker has a cached description - if so, use it instead of sending the image
|
||||||
|
const cachedStickerDescription = allMedia[0]?.stickerMetadata?.cachedDescription;
|
||||||
|
const stickerSupportsVision = msg.sticker
|
||||||
|
? await resolveStickerVisionSupport({ cfg, agentId: route.agentId })
|
||||||
|
: false;
|
||||||
|
const stickerCacheHit = Boolean(cachedStickerDescription) && !stickerSupportsVision;
|
||||||
|
if (stickerCacheHit) {
|
||||||
|
// Format cached description with sticker context
|
||||||
|
const emoji = allMedia[0]?.stickerMetadata?.emoji;
|
||||||
|
const setName = allMedia[0]?.stickerMetadata?.setName;
|
||||||
|
const stickerContext = [emoji, setName ? `from "${setName}"` : null].filter(Boolean).join(" ");
|
||||||
|
placeholder = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${cachedStickerDescription}`;
|
||||||
|
}
|
||||||
|
|
||||||
const locationData = extractTelegramLocation(msg);
|
const locationData = extractTelegramLocation(msg);
|
||||||
const locationText = locationData ? formatLocationText(locationData) : undefined;
|
const locationText = locationData ? formatLocationText(locationData) : undefined;
|
||||||
@ -525,15 +574,26 @@ export const buildTelegramMessageContext = async ({
|
|||||||
ForwardedDate: forwardOrigin?.date ? forwardOrigin.date * 1000 : undefined,
|
ForwardedDate: forwardOrigin?.date ? forwardOrigin.date * 1000 : undefined,
|
||||||
Timestamp: msg.date ? msg.date * 1000 : undefined,
|
Timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||||
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
|
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
|
||||||
MediaPath: allMedia[0]?.path,
|
// Filter out cached stickers from media - their description is already in the message body
|
||||||
MediaType: allMedia[0]?.contentType,
|
MediaPath: stickerCacheHit ? undefined : allMedia[0]?.path,
|
||||||
MediaUrl: allMedia[0]?.path,
|
MediaType: stickerCacheHit ? undefined : allMedia[0]?.contentType,
|
||||||
MediaPaths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
|
MediaUrl: stickerCacheHit ? undefined : allMedia[0]?.path,
|
||||||
MediaUrls: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
|
MediaPaths: stickerCacheHit
|
||||||
MediaTypes:
|
? undefined
|
||||||
allMedia.length > 0
|
: allMedia.length > 0
|
||||||
|
? allMedia.map((m) => m.path)
|
||||||
|
: undefined,
|
||||||
|
MediaUrls: stickerCacheHit
|
||||||
|
? undefined
|
||||||
|
: allMedia.length > 0
|
||||||
|
? allMedia.map((m) => m.path)
|
||||||
|
: undefined,
|
||||||
|
MediaTypes: stickerCacheHit
|
||||||
|
? undefined
|
||||||
|
: allMedia.length > 0
|
||||||
? (allMedia.map((m) => m.contentType).filter(Boolean) as string[])
|
? (allMedia.map((m) => m.contentType).filter(Boolean) as string[])
|
||||||
: undefined,
|
: undefined,
|
||||||
|
Sticker: allMedia[0]?.stickerMetadata,
|
||||||
...(locationData ? toLocationContext(locationData) : undefined),
|
...(locationData ? toLocationContext(locationData) : undefined),
|
||||||
CommandAuthorized: commandAuthorized,
|
CommandAuthorized: commandAuthorized,
|
||||||
MessageThreadId: resolvedThreadId,
|
MessageThreadId: resolvedThreadId,
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js";
|
import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js";
|
||||||
|
import {
|
||||||
|
findModelInCatalog,
|
||||||
|
loadModelCatalog,
|
||||||
|
modelSupportsVision,
|
||||||
|
} from "../agents/model-catalog.js";
|
||||||
|
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
|
||||||
import { resolveChunkMode } from "../auto-reply/chunk.js";
|
import { resolveChunkMode } from "../auto-reply/chunk.js";
|
||||||
import { clearHistoryEntriesIfEnabled } from "../auto-reply/reply/history.js";
|
import { clearHistoryEntriesIfEnabled } from "../auto-reply/reply/history.js";
|
||||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
||||||
@ -12,6 +18,20 @@ import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
|||||||
import { deliverReplies } from "./bot/delivery.js";
|
import { deliverReplies } from "./bot/delivery.js";
|
||||||
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
|
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
|
||||||
import { createTelegramDraftStream } from "./draft-stream.js";
|
import { createTelegramDraftStream } from "./draft-stream.js";
|
||||||
|
import { cacheSticker, describeStickerImage } from "./sticker-cache.js";
|
||||||
|
import { resolveAgentDir } from "../agents/agent-scope.js";
|
||||||
|
|
||||||
|
async function resolveStickerVisionSupport(cfg, agentId) {
|
||||||
|
try {
|
||||||
|
const catalog = await loadModelCatalog({ config: cfg });
|
||||||
|
const defaultModel = resolveDefaultModelForAgent({ cfg, agentId });
|
||||||
|
const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model);
|
||||||
|
if (!entry) return false;
|
||||||
|
return modelSupportsVision(entry);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const dispatchTelegramMessage = async ({
|
export const dispatchTelegramMessage = async ({
|
||||||
context,
|
context,
|
||||||
@ -128,6 +148,56 @@ export const dispatchTelegramMessage = async ({
|
|||||||
});
|
});
|
||||||
const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId);
|
const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId);
|
||||||
|
|
||||||
|
// Handle uncached stickers: get a dedicated vision description before dispatch
|
||||||
|
// This ensures we cache a raw description rather than a conversational response
|
||||||
|
const sticker = ctxPayload.Sticker;
|
||||||
|
if (sticker?.fileUniqueId && ctxPayload.MediaPath) {
|
||||||
|
const agentDir = resolveAgentDir(cfg, route.agentId);
|
||||||
|
const stickerSupportsVision = await resolveStickerVisionSupport(cfg, route.agentId);
|
||||||
|
let description = sticker.cachedDescription ?? null;
|
||||||
|
if (!description) {
|
||||||
|
description = await describeStickerImage({
|
||||||
|
imagePath: ctxPayload.MediaPath,
|
||||||
|
cfg,
|
||||||
|
agentDir,
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (description) {
|
||||||
|
// Format the description with sticker context
|
||||||
|
const stickerContext = [sticker.emoji, sticker.setName ? `from "${sticker.setName}"` : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
const formattedDesc = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${description}`;
|
||||||
|
|
||||||
|
sticker.cachedDescription = description;
|
||||||
|
if (!stickerSupportsVision) {
|
||||||
|
// Update context to use description instead of image
|
||||||
|
ctxPayload.Body = formattedDesc;
|
||||||
|
ctxPayload.BodyForAgent = formattedDesc;
|
||||||
|
// Clear media paths so native vision doesn't process the image again
|
||||||
|
ctxPayload.MediaPath = undefined;
|
||||||
|
ctxPayload.MediaType = undefined;
|
||||||
|
ctxPayload.MediaUrl = undefined;
|
||||||
|
ctxPayload.MediaPaths = undefined;
|
||||||
|
ctxPayload.MediaUrls = undefined;
|
||||||
|
ctxPayload.MediaTypes = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the description for future encounters
|
||||||
|
cacheSticker({
|
||||||
|
fileId: sticker.fileId,
|
||||||
|
fileUniqueId: sticker.fileUniqueId,
|
||||||
|
emoji: sticker.emoji,
|
||||||
|
setName: sticker.setName,
|
||||||
|
description,
|
||||||
|
cachedAt: new Date().toISOString(),
|
||||||
|
receivedFrom: ctxPayload.From,
|
||||||
|
});
|
||||||
|
logVerbose(`telegram: cached sticker description for ${sticker.fileUniqueId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
|
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg,
|
cfg,
|
||||||
@ -139,6 +209,7 @@ export const dispatchTelegramMessage = async ({
|
|||||||
await flushDraft();
|
await flushDraft();
|
||||||
draftStream?.stop();
|
draftStream?.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
await deliverReplies({
|
await deliverReplies({
|
||||||
replies: [payload],
|
replies: [payload],
|
||||||
chatId: String(chatId),
|
chatId: String(chatId),
|
||||||
|
|||||||
@ -7,6 +7,9 @@ const middlewareUseSpy = vi.fn();
|
|||||||
const onSpy = vi.fn();
|
const onSpy = vi.fn();
|
||||||
const stopSpy = vi.fn();
|
const stopSpy = vi.fn();
|
||||||
const sendChatActionSpy = vi.fn();
|
const sendChatActionSpy = vi.fn();
|
||||||
|
const cacheStickerSpy = vi.fn();
|
||||||
|
const getCachedStickerSpy = vi.fn();
|
||||||
|
const describeStickerImageSpy = vi.fn();
|
||||||
|
|
||||||
type ApiStub = {
|
type ApiStub = {
|
||||||
config: { use: (arg: unknown) => void };
|
config: { use: (arg: unknown) => void };
|
||||||
@ -79,6 +82,12 @@ vi.mock("../config/sessions.js", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("./sticker-cache.js", () => ({
|
||||||
|
cacheSticker: (...args: unknown[]) => cacheStickerSpy(...args),
|
||||||
|
getCachedSticker: (...args: unknown[]) => getCachedStickerSpy(...args),
|
||||||
|
describeStickerImage: (...args: unknown[]) => describeStickerImageSpy(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("./pairing-store.js", () => ({
|
vi.mock("./pairing-store.js", () => ({
|
||||||
readTelegramAllowFromStore: vi.fn(async () => [] as string[]),
|
readTelegramAllowFromStore: vi.fn(async () => [] as string[]),
|
||||||
upsertTelegramPairingRequest: vi.fn(async () => ({
|
upsertTelegramPairingRequest: vi.fn(async () => ({
|
||||||
@ -405,6 +414,290 @@ describe("telegram media groups", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("telegram stickers", () => {
|
||||||
|
const STICKER_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 20_000;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cacheStickerSpy.mockReset();
|
||||||
|
getCachedStickerSpy.mockReset();
|
||||||
|
describeStickerImageSpy.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
"downloads static sticker (WEBP) and includes sticker metadata",
|
||||||
|
async () => {
|
||||||
|
const { createTelegramBot } = await import("./bot.js");
|
||||||
|
const replyModule = await import("../auto-reply/reply.js");
|
||||||
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
onSpy.mockReset();
|
||||||
|
replySpy.mockReset();
|
||||||
|
sendChatActionSpy.mockReset();
|
||||||
|
|
||||||
|
const runtimeLog = vi.fn();
|
||||||
|
const runtimeError = vi.fn();
|
||||||
|
createTelegramBot({
|
||||||
|
token: "tok",
|
||||||
|
runtime: {
|
||||||
|
log: runtimeLog,
|
||||||
|
error: runtimeError,
|
||||||
|
exit: () => {
|
||||||
|
throw new Error("exit");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const handler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as (
|
||||||
|
ctx: Record<string, unknown>,
|
||||||
|
) => Promise<void>;
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, "fetch" as never).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
headers: { get: () => "image/webp" },
|
||||||
|
arrayBuffer: async () => new Uint8Array([0x52, 0x49, 0x46, 0x46]).buffer, // RIFF header
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
message: {
|
||||||
|
message_id: 100,
|
||||||
|
chat: { id: 1234, type: "private" },
|
||||||
|
sticker: {
|
||||||
|
file_id: "sticker_file_id_123",
|
||||||
|
file_unique_id: "sticker_unique_123",
|
||||||
|
type: "regular",
|
||||||
|
width: 512,
|
||||||
|
height: 512,
|
||||||
|
is_animated: false,
|
||||||
|
is_video: false,
|
||||||
|
emoji: "🎉",
|
||||||
|
set_name: "TestStickerPack",
|
||||||
|
},
|
||||||
|
date: 1736380800,
|
||||||
|
},
|
||||||
|
me: { username: "clawdbot_bot" },
|
||||||
|
getFile: async () => ({ file_path: "stickers/sticker.webp" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runtimeError).not.toHaveBeenCalled();
|
||||||
|
expect(fetchSpy).toHaveBeenCalledWith(
|
||||||
|
"https://api.telegram.org/file/bottok/stickers/sticker.webp",
|
||||||
|
);
|
||||||
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = replySpy.mock.calls[0][0];
|
||||||
|
expect(payload.Body).toContain("<media:sticker>");
|
||||||
|
expect(payload.Sticker?.emoji).toBe("🎉");
|
||||||
|
expect(payload.Sticker?.setName).toBe("TestStickerPack");
|
||||||
|
expect(payload.Sticker?.fileId).toBe("sticker_file_id_123");
|
||||||
|
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
},
|
||||||
|
STICKER_TEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"refreshes cached sticker metadata on cache hit",
|
||||||
|
async () => {
|
||||||
|
const { createTelegramBot } = await import("./bot.js");
|
||||||
|
const replyModule = await import("../auto-reply/reply.js");
|
||||||
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
onSpy.mockReset();
|
||||||
|
replySpy.mockReset();
|
||||||
|
sendChatActionSpy.mockReset();
|
||||||
|
|
||||||
|
getCachedStickerSpy.mockReturnValue({
|
||||||
|
fileId: "old_file_id",
|
||||||
|
fileUniqueId: "sticker_unique_456",
|
||||||
|
emoji: "😴",
|
||||||
|
setName: "OldSet",
|
||||||
|
description: "Cached description",
|
||||||
|
cachedAt: "2026-01-20T10:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtimeError = vi.fn();
|
||||||
|
createTelegramBot({
|
||||||
|
token: "tok",
|
||||||
|
runtime: {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: runtimeError,
|
||||||
|
exit: () => {
|
||||||
|
throw new Error("exit");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const handler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as (
|
||||||
|
ctx: Record<string, unknown>,
|
||||||
|
) => Promise<void>;
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, "fetch" as never).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
headers: { get: () => "image/webp" },
|
||||||
|
arrayBuffer: async () => new Uint8Array([0x52, 0x49, 0x46, 0x46]).buffer,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
message: {
|
||||||
|
message_id: 103,
|
||||||
|
chat: { id: 1234, type: "private" },
|
||||||
|
sticker: {
|
||||||
|
file_id: "new_file_id",
|
||||||
|
file_unique_id: "sticker_unique_456",
|
||||||
|
type: "regular",
|
||||||
|
width: 512,
|
||||||
|
height: 512,
|
||||||
|
is_animated: false,
|
||||||
|
is_video: false,
|
||||||
|
emoji: "🔥",
|
||||||
|
set_name: "NewSet",
|
||||||
|
},
|
||||||
|
date: 1736380800,
|
||||||
|
},
|
||||||
|
me: { username: "clawdbot_bot" },
|
||||||
|
getFile: async () => ({ file_path: "stickers/sticker.webp" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runtimeError).not.toHaveBeenCalled();
|
||||||
|
expect(cacheStickerSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
fileId: "new_file_id",
|
||||||
|
emoji: "🔥",
|
||||||
|
setName: "NewSet",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const payload = replySpy.mock.calls[0][0];
|
||||||
|
expect(payload.Sticker?.fileId).toBe("new_file_id");
|
||||||
|
expect(payload.Sticker?.cachedDescription).toBe("Cached description");
|
||||||
|
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
},
|
||||||
|
STICKER_TEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"skips animated stickers (TGS format)",
|
||||||
|
async () => {
|
||||||
|
const { createTelegramBot } = await import("./bot.js");
|
||||||
|
const replyModule = await import("../auto-reply/reply.js");
|
||||||
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
onSpy.mockReset();
|
||||||
|
replySpy.mockReset();
|
||||||
|
|
||||||
|
const runtimeError = vi.fn();
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, "fetch" as never);
|
||||||
|
|
||||||
|
createTelegramBot({
|
||||||
|
token: "tok",
|
||||||
|
runtime: {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: runtimeError,
|
||||||
|
exit: () => {
|
||||||
|
throw new Error("exit");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const handler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as (
|
||||||
|
ctx: Record<string, unknown>,
|
||||||
|
) => Promise<void>;
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
message: {
|
||||||
|
message_id: 101,
|
||||||
|
chat: { id: 1234, type: "private" },
|
||||||
|
sticker: {
|
||||||
|
file_id: "animated_sticker_id",
|
||||||
|
file_unique_id: "animated_unique",
|
||||||
|
type: "regular",
|
||||||
|
width: 512,
|
||||||
|
height: 512,
|
||||||
|
is_animated: true, // TGS format
|
||||||
|
is_video: false,
|
||||||
|
emoji: "😎",
|
||||||
|
set_name: "AnimatedPack",
|
||||||
|
},
|
||||||
|
date: 1736380800,
|
||||||
|
},
|
||||||
|
me: { username: "clawdbot_bot" },
|
||||||
|
getFile: async () => ({ file_path: "stickers/animated.tgs" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not attempt to download animated stickers
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
// Should still process the message (as text-only, no media)
|
||||||
|
expect(replySpy).not.toHaveBeenCalled(); // No text content, so no reply generated
|
||||||
|
expect(runtimeError).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
},
|
||||||
|
STICKER_TEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"skips video stickers (WEBM format)",
|
||||||
|
async () => {
|
||||||
|
const { createTelegramBot } = await import("./bot.js");
|
||||||
|
const replyModule = await import("../auto-reply/reply.js");
|
||||||
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
onSpy.mockReset();
|
||||||
|
replySpy.mockReset();
|
||||||
|
|
||||||
|
const runtimeError = vi.fn();
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, "fetch" as never);
|
||||||
|
|
||||||
|
createTelegramBot({
|
||||||
|
token: "tok",
|
||||||
|
runtime: {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: runtimeError,
|
||||||
|
exit: () => {
|
||||||
|
throw new Error("exit");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const handler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as (
|
||||||
|
ctx: Record<string, unknown>,
|
||||||
|
) => Promise<void>;
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
message: {
|
||||||
|
message_id: 102,
|
||||||
|
chat: { id: 1234, type: "private" },
|
||||||
|
sticker: {
|
||||||
|
file_id: "video_sticker_id",
|
||||||
|
file_unique_id: "video_unique",
|
||||||
|
type: "regular",
|
||||||
|
width: 512,
|
||||||
|
height: 512,
|
||||||
|
is_animated: false,
|
||||||
|
is_video: true, // WEBM format
|
||||||
|
emoji: "🎬",
|
||||||
|
set_name: "VideoPack",
|
||||||
|
},
|
||||||
|
date: 1736380800,
|
||||||
|
},
|
||||||
|
me: { username: "clawdbot_bot" },
|
||||||
|
getFile: async () => ({ file_path: "stickers/video.webm" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not attempt to download video stickers
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
expect(replySpy).not.toHaveBeenCalled();
|
||||||
|
expect(runtimeError).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
},
|
||||||
|
STICKER_TEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
describe("telegram text fragments", () => {
|
describe("telegram text fragments", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user