Compare commits
No files matched your search
@@ -7,6 +7,7 @@ Status: unreleased.
|
||||
|
||||
### Changes
|
||||
- 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.
|
||||
- Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204.
|
||||
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
|
||||
@@ -52,6 +53,7 @@ Status: unreleased.
|
||||
- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
|
||||
- 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: add sticker receive/send with vision caching. (#2548) Thanks @longjos.
|
||||
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
|
||||
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
|
||||
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
|
||||
@@ -60,6 +62,8 @@ Status: unreleased.
|
||||
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
||||
|
||||
### 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.
|
||||
- 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.
|
||||
- Security: pin npm overrides to keep tar@7.5.4 for install toolchains.
|
||||
|
||||
@@ -13,6 +13,7 @@ public struct ClawdbotChatView: View {
|
||||
@State private var showSessions = false
|
||||
@State private var hasPerformedInitialScroll = false
|
||||
@State private var isPinnedToBottom = true
|
||||
@State private var lastUserMessageID: UUID?
|
||||
private let showsSessionSwitcher: Bool
|
||||
private let style: Style
|
||||
private let markdownVariant: ChatMarkdownVariant
|
||||
@@ -132,8 +133,28 @@ public struct ClawdbotChatView: View {
|
||||
self.hasPerformedInitialScroll = false
|
||||
self.isPinnedToBottom = true
|
||||
}
|
||||
.onChange(of: self.viewModel.isSending) { _, isSending in
|
||||
// Scroll to bottom when user sends a message, even if scrolled up.
|
||||
guard isSending, self.hasPerformedInitialScroll else { return }
|
||||
self.isPinnedToBottom = true
|
||||
withAnimation(.snappy(duration: 0.22)) {
|
||||
self.scrollPosition = self.scrollerBottomID
|
||||
}
|
||||
}
|
||||
.onChange(of: self.viewModel.messages.count) { _, _ in
|
||||
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
|
||||
guard self.hasPerformedInitialScroll else { return }
|
||||
if let lastMessage = self.viewModel.messages.last,
|
||||
lastMessage.role.lowercased() == "user",
|
||||
lastMessage.id != self.lastUserMessageID {
|
||||
self.lastUserMessageID = lastMessage.id
|
||||
self.isPinnedToBottom = true
|
||||
withAnimation(.snappy(duration: 0.22)) {
|
||||
self.scrollPosition = self.scrollerBottomID
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard self.isPinnedToBottom else { return }
|
||||
withAnimation(.snappy(duration: 0.22)) {
|
||||
self.scrollPosition = self.scrollerBottomID
|
||||
}
|
||||
|
||||
@@ -218,6 +218,7 @@ Prefer `chat_guid` for stable routing:
|
||||
## Security
|
||||
- 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).
|
||||
- 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.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -298,8 +298,12 @@ ack reaction after the bot replies.
|
||||
- `guilds."*"`: default per-guild settings applied when no explicit entry exists.
|
||||
- `guilds.<id>.slug`: optional friendly slug used for display 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>.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>.skills`: skill filter (omit = all skills, empty = none).
|
||||
- `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.teams.<teamId>.replyStyle`: 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>.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)).
|
||||
|
||||
## 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>`):
|
||||
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`.
|
||||
- `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).
|
||||
- `users`: optional per-channel user allowlist.
|
||||
- `skills`: skill filter (omit = all skills, empty = none).
|
||||
|
||||
+129
-1
@@ -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)
|
||||
Telegram can stream **draft bubbles** while the agent is generating a response.
|
||||
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 `deleteMessage` action (`chatId`, `messageId`).
|
||||
- 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
|
||||
|
||||
@@ -537,6 +664,7 @@ Provider options:
|
||||
- `channels.telegram.actions.reactions`: gate Telegram tool reactions.
|
||||
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
|
||||
- `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.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).
|
||||
- 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
|
||||
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.
|
||||
|
||||
|
||||
+1
-1
@@ -956,7 +956,7 @@
|
||||
"gateway/doctor",
|
||||
"gateway/logging",
|
||||
"gateway/security",
|
||||
"gateway/security-formal-verification",
|
||||
"security/formal-verification",
|
||||
"gateway/sandbox-vs-tool-policy-vs-elevated",
|
||||
"gateway/sandboxing",
|
||||
"gateway/troubleshooting",
|
||||
|
||||
@@ -2768,6 +2768,7 @@ scheme/host for profiles that only set `cdpPort`.
|
||||
|
||||
Defaults:
|
||||
- enabled: `true`
|
||||
- evaluateEnabled: `true` (set `false` to disable `act:evaluate` and `wait --fn`)
|
||||
- control service: loopback only (port derived from `gateway.port`, default `18791`)
|
||||
- CDP URL: `http://127.0.0.1:18792` (control service + 1, legacy single-profile)
|
||||
- profile color: `#FF4500` (lobster-orange)
|
||||
@@ -2778,6 +2779,7 @@ Defaults:
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
evaluateEnabled: true,
|
||||
// cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
|
||||
defaultProfile: "chrome",
|
||||
profiles: {
|
||||
|
||||
@@ -1,107 +1,12 @@
|
||||
---
|
||||
title: Formal Verification (Security Models)
|
||||
summary: Machine-checked security models for Clawdbot’s highest-risk paths.
|
||||
permalink: /gateway/security-formal-verification/
|
||||
summary: Redirect to the canonical Formal Verification page.
|
||||
permalink: /gateway/security/formal-verification/
|
||||
---
|
||||
|
||||
# Formal Verification (Security Models)
|
||||
This page moved to: [/security/formal-verification/](/security/formal-verification/)
|
||||
|
||||
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: [github.com/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)
|
||||
<script>
|
||||
// Best-effort client-side redirect for Mintlify/Next.
|
||||
window.location.replace("/security/formal-verification/");
|
||||
</script>
|
||||
@@ -7,7 +7,7 @@ read_when:
|
||||
|
||||
## Quick check: `clawdbot security audit`
|
||||
|
||||
See also: [Formal Verification (Security Models)](/gateway/security-formal-verification/)
|
||||
See also: [Formal Verification (Security Models)](/security/formal-verification/)
|
||||
|
||||
Run this regularly (especially after changing config or exposing network surfaces):
|
||||
|
||||
@@ -572,6 +572,9 @@ If that browser profile already contains logged-in sessions, the model can
|
||||
access those accounts and data. Treat browser profiles as **sensitive state**:
|
||||
- Prefer a dedicated profile for the agent (the default `clawd` profile).
|
||||
- Avoid pointing the agent at your personal daily-driver profile.
|
||||
- `act:evaluate` and `wait --fn` run arbitrary JavaScript in the page context.
|
||||
Prompt injection can steer the model into calling them. If you do not need
|
||||
them, set `browser.evaluateEnabled=false` (see [Configuration](/gateway/configuration#browser-clawd-managed-browser)).
|
||||
- Keep host browser control disabled for sandboxed agents unless you trust them.
|
||||
- Treat browser downloads as untrusted input; prefer an isolated downloads directory.
|
||||
- Disable browser sync/password managers in the agent profile if possible (reduces blast radius).
|
||||
|
||||
@@ -1,5 +1,107 @@
|
||||
---
|
||||
title: Formal Verification (Security Models)
|
||||
summary: Machine-checked security models for Clawdbot’s highest-risk paths.
|
||||
permalink: /security/formal-verification/
|
||||
---
|
||||
|
||||
This page moved to: [/gateway/security-formal-verification/](/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)
|
||||
@@ -505,6 +505,9 @@ These are useful for “make the site behave like X” workflows:
|
||||
## Security & privacy
|
||||
|
||||
- The clawd browser profile may contain logged-in sessions; treat it as sensitive.
|
||||
- `browser act kind=evaluate` / `clawdbot browser evaluate` and `wait --fn`
|
||||
execute arbitrary JavaScript in the page context. Prompt injection can steer
|
||||
this. Disable it with `browser.evaluateEnabled=false` if you do not need it.
|
||||
- For logins and anti-bot notes (X/Twitter, etc.), see [Browser login + X/Twitter posting](/tools/browser-login).
|
||||
- Keep the Gateway/node host private (loopback or tailnet-only).
|
||||
- Remote CDP endpoints are powerful; tunnel and protect them.
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import {
|
||||
buildChannelKeyCandidates,
|
||||
normalizeChannelSlug,
|
||||
resolveToolsBySender,
|
||||
resolveChannelEntryMatchWithFallback,
|
||||
resolveNestedAllowlistDecision,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
@@ -106,9 +107,36 @@ export function resolveMSTeamsGroupToolPolicy(
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -125,7 +153,24 @@ export function resolveMSTeamsGroupToolPolicy(
|
||||
normalizeKey: normalizeChannelSlug,
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,10 @@ describe("gateway tool", () => {
|
||||
vi.useFakeTimers();
|
||||
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const previousProfile = process.env.CLAWDBOT_PROFILE;
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-test-"));
|
||||
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
||||
process.env.CLAWDBOT_PROFILE = "isolated";
|
||||
|
||||
try {
|
||||
const tool = createClawdbotTools({
|
||||
@@ -62,6 +64,11 @@ describe("gateway tool", () => {
|
||||
} else {
|
||||
process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
||||
}
|
||||
if (previousProfile === undefined) {
|
||||
delete process.env.CLAWDBOT_PROFILE;
|
||||
} else {
|
||||
process.env.CLAWDBOT_PROFILE = previousProfile;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -119,9 +119,16 @@ function mergeConfig(
|
||||
const provider = overrides?.provider ?? defaults?.provider ?? "auto";
|
||||
const defaultRemote = defaults?.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 =
|
||||
hasRemote || provider === "openai" || provider === "gemini" || provider === "auto";
|
||||
hasRemoteConfig || provider === "openai" || provider === "gemini" || provider === "auto";
|
||||
const batch = {
|
||||
enabled: overrideRemote?.batch?.enabled ?? defaultRemote?.batch?.enabled ?? true,
|
||||
wait: overrideRemote?.batch?.wait ?? defaultRemote?.batch?.wait ?? true,
|
||||
|
||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { ensureClawdbotModelsJson } from "./models-config.js";
|
||||
|
||||
|
||||
@@ -215,6 +215,10 @@ export async function runEmbeddedAttempt(
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
spawnedBy: params.spawnedBy,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
agentDir,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
|
||||
@@ -35,6 +35,10 @@ export type RunEmbeddedPiAgentParams = {
|
||||
groupSpace?: string | null;
|
||||
/** Parent session key for subagent policy inheritance. */
|
||||
spawnedBy?: string | null;
|
||||
senderId?: string | null;
|
||||
senderName?: string | null;
|
||||
senderUsername?: string | null;
|
||||
senderE164?: string | null;
|
||||
/** Current channel ID for auto-threading (Slack). */
|
||||
currentChannelId?: string;
|
||||
/** Current thread timestamp for auto-threading (Slack). */
|
||||
|
||||
@@ -31,6 +31,10 @@ export type EmbeddedRunAttemptParams = {
|
||||
groupSpace?: string | null;
|
||||
/** Parent session key for subagent policy inheritance. */
|
||||
spawnedBy?: string | null;
|
||||
senderId?: string | null;
|
||||
senderName?: string | null;
|
||||
senderUsername?: string | null;
|
||||
senderE164?: string | null;
|
||||
currentChannelId?: string;
|
||||
currentThreadTs?: string;
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||
import type { SandboxDockerConfig } from "./sandbox.js";
|
||||
@@ -270,6 +271,75 @@ describe("Agent-specific tool filtering", () => {
|
||||
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", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
|
||||
@@ -233,6 +233,10 @@ export function resolveGroupToolPolicy(params: {
|
||||
groupChannel?: string | null;
|
||||
groupSpace?: string | null;
|
||||
accountId?: string | null;
|
||||
senderId?: string | null;
|
||||
senderName?: string | null;
|
||||
senderUsername?: string | null;
|
||||
senderE164?: string | null;
|
||||
}): SandboxToolPolicy | undefined {
|
||||
if (!params.config) return undefined;
|
||||
const sessionContext = resolveGroupContextFromSessionKey(params.sessionKey);
|
||||
@@ -255,12 +259,20 @@ export function resolveGroupToolPolicy(params: {
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
accountId: params.accountId,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
}) ??
|
||||
resolveChannelGroupToolsPolicy({
|
||||
cfg: params.config,
|
||||
channel,
|
||||
groupId,
|
||||
accountId: params.accountId,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
return pickToolPolicy(toolsConfig);
|
||||
}
|
||||
|
||||
@@ -140,6 +140,10 @@ export function createClawdbotCodingTools(options?: {
|
||||
groupSpace?: string | null;
|
||||
/** Parent session key for subagent group policy inheritance. */
|
||||
spawnedBy?: string | null;
|
||||
senderId?: string | null;
|
||||
senderName?: string | null;
|
||||
senderUsername?: string | null;
|
||||
senderE164?: string | null;
|
||||
/** Reply-to mode for Slack auto-threading. */
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
||||
@@ -174,6 +178,10 @@ export function createClawdbotCodingTools(options?: {
|
||||
groupChannel: options?.groupChannel,
|
||||
groupSpace: options?.groupSpace,
|
||||
accountId: options?.agentAccountId,
|
||||
senderId: options?.senderId,
|
||||
senderName: options?.senderName,
|
||||
senderUsername: options?.senderUsername,
|
||||
senderE164: options?.senderE164,
|
||||
});
|
||||
const profilePolicy = resolveToolProfilePolicy(profile);
|
||||
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../../browser/bridge-server.js";
|
||||
import { type ResolvedBrowserConfig, resolveProfile } from "../../browser/config.js";
|
||||
import { DEFAULT_CLAWD_BROWSER_COLOR } from "../../browser/constants.js";
|
||||
import {
|
||||
DEFAULT_BROWSER_EVALUATE_ENABLED,
|
||||
DEFAULT_CLAWD_BROWSER_COLOR,
|
||||
} from "../../browser/constants.js";
|
||||
import { BROWSER_BRIDGES } from "./browser-bridges.js";
|
||||
import { DEFAULT_SANDBOX_BROWSER_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
|
||||
import {
|
||||
@@ -39,10 +42,12 @@ function buildSandboxBrowserResolvedConfig(params: {
|
||||
controlPort: number;
|
||||
cdpPort: number;
|
||||
headless: boolean;
|
||||
evaluateEnabled: boolean;
|
||||
}): ResolvedBrowserConfig {
|
||||
const cdpHost = "127.0.0.1";
|
||||
return {
|
||||
enabled: true,
|
||||
evaluateEnabled: params.evaluateEnabled,
|
||||
controlPort: params.controlPort,
|
||||
cdpProtocol: "http",
|
||||
cdpHost,
|
||||
@@ -76,6 +81,7 @@ export async function ensureSandboxBrowser(params: {
|
||||
workspaceDir: string;
|
||||
agentWorkspaceDir: string;
|
||||
cfg: SandboxConfig;
|
||||
evaluateEnabled?: boolean;
|
||||
}): Promise<SandboxBrowserContext | null> {
|
||||
if (!params.cfg.browser.enabled) return null;
|
||||
if (!isToolAllowed(params.cfg.tools, "browser")) return null;
|
||||
@@ -170,6 +176,7 @@ export async function ensureSandboxBrowser(params: {
|
||||
controlPort: 0,
|
||||
cdpPort: mappedCdp,
|
||||
headless: params.cfg.browser.headless,
|
||||
evaluateEnabled: params.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED,
|
||||
}),
|
||||
onEnsureAttachTarget,
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { DEFAULT_BROWSER_EVALUATE_ENABLED } from "../../browser/constants.js";
|
||||
import { syncSkillsToWorkspace } from "../skills.js";
|
||||
import { DEFAULT_AGENT_WORKSPACE_DIR } from "../workspace.js";
|
||||
import { ensureSandboxBrowser } from "./browser.js";
|
||||
@@ -69,11 +70,14 @@ export async function resolveSandboxContext(params: {
|
||||
cfg,
|
||||
});
|
||||
|
||||
const evaluateEnabled =
|
||||
params.config?.browser?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED;
|
||||
const browser = await ensureSandboxBrowser({
|
||||
scopeKey,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir,
|
||||
cfg,
|
||||
evaluateEnabled,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { SkillEligibilityContext, SkillEntry } from "./types.js";
|
||||
|
||||
const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
|
||||
"browser.enabled": true,
|
||||
"browser.evaluateEnabled": true,
|
||||
};
|
||||
|
||||
function isTruthy(value: unknown): boolean {
|
||||
|
||||
@@ -8,12 +8,17 @@ const sendMessageTelegram = vi.fn(async () => ({
|
||||
messageId: "789",
|
||||
chatId: "123",
|
||||
}));
|
||||
const sendStickerTelegram = vi.fn(async () => ({
|
||||
messageId: "456",
|
||||
chatId: "123",
|
||||
}));
|
||||
const deleteMessageTelegram = vi.fn(async () => ({ ok: true }));
|
||||
const originalToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
|
||||
vi.mock("../../telegram/send.js", () => ({
|
||||
reactMessageTelegram: (...args: unknown[]) => reactMessageTelegram(...args),
|
||||
sendMessageTelegram: (...args: unknown[]) => sendMessageTelegram(...args),
|
||||
sendStickerTelegram: (...args: unknown[]) => sendStickerTelegram(...args),
|
||||
deleteMessageTelegram: (...args: unknown[]) => deleteMessageTelegram(...args),
|
||||
}));
|
||||
|
||||
@@ -21,6 +26,7 @@ describe("handleTelegramAction", () => {
|
||||
beforeEach(() => {
|
||||
reactMessageTelegram.mockClear();
|
||||
sendMessageTelegram.mockClear();
|
||||
sendStickerTelegram.mockClear();
|
||||
deleteMessageTelegram.mockClear();
|
||||
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 () => {
|
||||
const cfg = {
|
||||
channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } },
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
editMessageTelegram,
|
||||
reactMessageTelegram,
|
||||
sendMessageTelegram,
|
||||
sendStickerTelegram,
|
||||
} from "../../telegram/send.js";
|
||||
import { getCacheStats, searchStickers } from "../../telegram/sticker-cache.js";
|
||||
import { resolveTelegramToken } from "../../telegram/token.js";
|
||||
import {
|
||||
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}`);
|
||||
}
|
||||
@@ -232,6 +232,10 @@ export async function runAgentTurnWithFallback(params: {
|
||||
groupChannel:
|
||||
params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(),
|
||||
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
|
||||
...buildThreadingToolContext({
|
||||
sessionCtx: params.sessionCtx,
|
||||
|
||||
@@ -115,6 +115,10 @@ export async function runMemoryFlushIfNeeded(params: {
|
||||
config: params.followupRun.run.config,
|
||||
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,
|
||||
workspaceDir: params.followupRun.run.workspaceDir,
|
||||
agentDir: params.followupRun.run.agentDir,
|
||||
|
||||
@@ -147,6 +147,10 @@ export function createFollowupRunner(params: {
|
||||
groupId: queued.run.groupId,
|
||||
groupChannel: queued.run.groupChannel,
|
||||
groupSpace: queued.run.groupSpace,
|
||||
senderId: queued.run.senderId,
|
||||
senderName: queued.run.senderName,
|
||||
senderUsername: queued.run.senderUsername,
|
||||
senderE164: queued.run.senderE164,
|
||||
sessionFile: queued.run.sessionFile,
|
||||
workspaceDir: queued.run.workspaceDir,
|
||||
config: queued.run.config,
|
||||
|
||||
@@ -370,6 +370,10 @@ export async function runPreparedReply(
|
||||
groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined,
|
||||
groupChannel: sessionCtx.GroupChannel?.trim() ?? sessionCtx.GroupSubject?.trim(),
|
||||
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,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
|
||||
@@ -51,6 +51,10 @@ export type FollowupRun = {
|
||||
groupId?: string;
|
||||
groupChannel?: string;
|
||||
groupSpace?: string;
|
||||
senderId?: string;
|
||||
senderName?: string;
|
||||
senderUsername?: string;
|
||||
senderE164?: string;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
config: ClawdbotConfig;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 { CommandArgs } from "./commands-registry.types.js";
|
||||
import type {
|
||||
@@ -64,6 +65,8 @@ export type MsgContext = {
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: string[];
|
||||
MediaTypes?: string[];
|
||||
/** Telegram sticker metadata (emoji, set name, file IDs, cached description). */
|
||||
Sticker?: StickerMetadata;
|
||||
OutputDir?: string;
|
||||
OutputBase?: string;
|
||||
/** Remote host for SCP when media lives on a different machine (e.g., clawdbot@192.168.64.3). */
|
||||
|
||||
@@ -14,7 +14,14 @@ function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number):
|
||||
? "If this is a sandboxed session, ensure the sandbox browser is running and try again."
|
||||
: `Start (or restart) the Clawdbot gateway (Clawdbot.app menubar, or \`${formatCliCommand("clawdbot gateway")}\`) and try again.`;
|
||||
const msg = String(err);
|
||||
if (msg.toLowerCase().includes("timed out") || msg.toLowerCase().includes("timeout")) {
|
||||
const msgLower = msg.toLowerCase();
|
||||
const looksLikeTimeout =
|
||||
msgLower.includes("timed out") ||
|
||||
msgLower.includes("timeout") ||
|
||||
msgLower.includes("aborted") ||
|
||||
msgLower.includes("abort") ||
|
||||
msgLower.includes("aborterror");
|
||||
if (looksLikeTimeout) {
|
||||
return new Error(
|
||||
`Can't reach the clawd browser control service (timed out after ${timeoutMs}ms). ${hint}`,
|
||||
);
|
||||
@@ -48,7 +55,7 @@ export async function fetchBrowserJson<T>(
|
||||
const timeoutMs = init?.timeoutMs ?? 5000;
|
||||
try {
|
||||
if (isAbsoluteHttp(url)) {
|
||||
return await fetchHttpJson<T>(url, init ? { ...init, timeoutMs } : { timeoutMs });
|
||||
return await fetchHttpJson<T>(url, { ...init, timeoutMs });
|
||||
}
|
||||
const started = await startBrowserControlServiceFromConfig();
|
||||
if (!started) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { resolveGatewayPort } from "../config/paths.js";
|
||||
import {
|
||||
DEFAULT_CLAWD_BROWSER_COLOR,
|
||||
DEFAULT_CLAWD_BROWSER_ENABLED,
|
||||
DEFAULT_BROWSER_EVALUATE_ENABLED,
|
||||
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
|
||||
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
|
||||
} from "./constants.js";
|
||||
@@ -15,6 +16,7 @@ import { CDP_PORT_RANGE_START, getUsedPorts } from "./profiles.js";
|
||||
|
||||
export type ResolvedBrowserConfig = {
|
||||
enabled: boolean;
|
||||
evaluateEnabled: boolean;
|
||||
controlPort: number;
|
||||
cdpProtocol: "http" | "https";
|
||||
cdpHost: string;
|
||||
@@ -140,6 +142,7 @@ export function resolveBrowserConfig(
|
||||
rootConfig?: ClawdbotConfig,
|
||||
): ResolvedBrowserConfig {
|
||||
const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED;
|
||||
const evaluateEnabled = cfg?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED;
|
||||
const gatewayPort = resolveGatewayPort(rootConfig);
|
||||
const controlPort = deriveDefaultBrowserControlPort(gatewayPort ?? DEFAULT_BROWSER_CONTROL_PORT);
|
||||
const defaultColor = normalizeHexColor(cfg?.color);
|
||||
@@ -197,6 +200,7 @@ export function resolveBrowserConfig(
|
||||
|
||||
return {
|
||||
enabled,
|
||||
evaluateEnabled,
|
||||
controlPort,
|
||||
cdpProtocol,
|
||||
cdpHost: cdpInfo.parsed.hostname,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const DEFAULT_CLAWD_BROWSER_ENABLED = true;
|
||||
export const DEFAULT_BROWSER_EVALUATE_ENABLED = true;
|
||||
export const DEFAULT_CLAWD_BROWSER_COLOR = "#FF4500";
|
||||
export const DEFAULT_CLAWD_BROWSER_PROFILE_NAME = "clawd";
|
||||
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "chrome";
|
||||
|
||||
@@ -39,6 +39,7 @@ export function registerBrowserAgentActRoutes(
|
||||
const cdpUrl = profileCtx.profile.cdpUrl;
|
||||
const pw = await requirePwAi(res, `act:${kind}`);
|
||||
if (!pw) return;
|
||||
const evaluateEnabled = ctx.state().resolved.evaluateEnabled;
|
||||
|
||||
switch (kind) {
|
||||
case "click": {
|
||||
@@ -210,6 +211,16 @@ export function registerBrowserAgentActRoutes(
|
||||
: undefined;
|
||||
const fn = toStringOrEmpty(body.fn) || undefined;
|
||||
const timeoutMs = toNumber(body.timeoutMs) ?? undefined;
|
||||
if (fn && !evaluateEnabled) {
|
||||
return jsonError(
|
||||
res,
|
||||
403,
|
||||
[
|
||||
"wait --fn is disabled by config (browser.evaluateEnabled=false).",
|
||||
"Docs: /gateway/configuration#browser-clawd-managed-browser",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
if (
|
||||
timeMs === undefined &&
|
||||
!text &&
|
||||
@@ -240,6 +251,16 @@ export function registerBrowserAgentActRoutes(
|
||||
return res.json({ ok: true, targetId: tab.targetId });
|
||||
}
|
||||
case "evaluate": {
|
||||
if (!evaluateEnabled) {
|
||||
return jsonError(
|
||||
res,
|
||||
403,
|
||||
[
|
||||
"act:evaluate is disabled by config (browser.evaluateEnabled=false).",
|
||||
"Docs: /gateway/configuration#browser-clawd-managed-browser",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
const fn = toStringOrEmpty(body.fn);
|
||||
if (!fn) return jsonError(res, 400, "fn is required");
|
||||
const ref = toStringOrEmpty(body.ref) || undefined;
|
||||
|
||||
@@ -7,6 +7,7 @@ let testPort = 0;
|
||||
let cdpBaseUrl = "";
|
||||
let reachable = false;
|
||||
let cfgAttachOnly = false;
|
||||
let cfgEvaluateEnabled = true;
|
||||
let createTargetId: string | null = null;
|
||||
let prevGatewayPort: string | undefined;
|
||||
|
||||
@@ -89,6 +90,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
loadConfig: () => ({
|
||||
browser: {
|
||||
enabled: true,
|
||||
evaluateEnabled: cfgEvaluateEnabled,
|
||||
color: "#FF4500",
|
||||
attachOnly: cfgAttachOnly,
|
||||
headless: true,
|
||||
@@ -185,6 +187,7 @@ describe("browser control server", () => {
|
||||
beforeEach(async () => {
|
||||
reachable = false;
|
||||
cfgAttachOnly = false;
|
||||
cfgEvaluateEnabled = true;
|
||||
createTargetId = null;
|
||||
|
||||
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
|
||||
@@ -349,6 +352,30 @@ describe("browser control server", () => {
|
||||
slowTimeoutMs,
|
||||
);
|
||||
|
||||
it(
|
||||
"blocks act:evaluate when browser.evaluateEnabled=false",
|
||||
async () => {
|
||||
cfgEvaluateEnabled = false;
|
||||
const base = await startServerAndBase();
|
||||
|
||||
const waitRes = (await postJson(`${base}/act`, {
|
||||
kind: "wait",
|
||||
fn: "() => window.ready === true",
|
||||
})) as { error?: string };
|
||||
expect(waitRes.error).toContain("browser.evaluateEnabled=false");
|
||||
expect(pwMocks.waitForViaPlaywright).not.toHaveBeenCalled();
|
||||
|
||||
const res = (await postJson(`${base}/act`, {
|
||||
kind: "evaluate",
|
||||
fn: "() => 1",
|
||||
})) as { error?: string };
|
||||
|
||||
expect(res.error).toContain("browser.evaluateEnabled=false");
|
||||
expect(pwMocks.evaluateViaPlaywright).not.toHaveBeenCalled();
|
||||
},
|
||||
slowTimeoutMs,
|
||||
);
|
||||
|
||||
it("agent contract: hooks + response + downloads + screenshot", async () => {
|
||||
const base = await startServerAndBase();
|
||||
|
||||
|
||||
@@ -308,6 +308,11 @@ describe("backward compatibility (profile parameter)", () => {
|
||||
|
||||
testPort = await getFreePort();
|
||||
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||
|
||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -344,6 +349,11 @@ describe("backward compatibility (profile parameter)", () => {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
if (prevGatewayPort === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
||||
}
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
|
||||
@@ -285,6 +285,11 @@ describe("profile CRUD endpoints", () => {
|
||||
|
||||
testPort = await getFreePort();
|
||||
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||
|
||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -299,6 +304,11 @@ describe("profile CRUD endpoints", () => {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
if (prevGatewayPort === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
||||
}
|
||||
const { stopBrowserControlServer } = await import("./server.js");
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
|
||||
@@ -1 +1 @@
|
||||
6d63b866aa0e917b278c6bef42229e8cd1f43c8ba31c845a96b4d9d5ce780265
|
||||
2567ca5bbc065b922d96717a488d5db3120b5b033c5d0508682d1aa8fbba470a
|
||||
@@ -10,6 +10,13 @@ vi.mock("../../../agents/tools/telegram-actions.js", () => ({
|
||||
}));
|
||||
|
||||
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 () => {
|
||||
handleTelegramAction.mockClear();
|
||||
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
createActionGate,
|
||||
readNumberParam,
|
||||
readStringArrayParam,
|
||||
readStringOrNumberParam,
|
||||
readStringParam,
|
||||
} from "../../../agents/tools/common.js";
|
||||
@@ -45,6 +46,10 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
if (gate("reactions")) actions.add("react");
|
||||
if (gate("deleteMessage")) actions.add("delete");
|
||||
if (gate("editMessage")) actions.add("edit");
|
||||
if (gate("sticker", false)) {
|
||||
actions.add("sticker");
|
||||
actions.add("sticker-search");
|
||||
}
|
||||
return Array.from(actions);
|
||||
},
|
||||
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}.`);
|
||||
},
|
||||
};
|
||||
@@ -2,9 +2,13 @@ import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
resolveChannelGroupRequireMention,
|
||||
resolveChannelGroupToolsPolicy,
|
||||
resolveToolsBySender,
|
||||
} from "../../config/group-policy.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";
|
||||
|
||||
type GroupMentionParams = {
|
||||
@@ -13,6 +17,10 @@ type GroupMentionParams = {
|
||||
groupChannel?: string | null;
|
||||
groupSpace?: string | null;
|
||||
accountId?: string | null;
|
||||
senderId?: string | null;
|
||||
senderName?: string | null;
|
||||
senderUsername?: string | null;
|
||||
senderE164?: string | null;
|
||||
};
|
||||
|
||||
function normalizeDiscordSlug(value?: string | null) {
|
||||
@@ -172,6 +180,10 @@ export function resolveGoogleChatGroupToolPolicy(
|
||||
channel: "googlechat",
|
||||
groupId: params.groupId,
|
||||
accountId: params.accountId,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -226,6 +238,10 @@ export function resolveTelegramGroupToolPolicy(
|
||||
channel: "telegram",
|
||||
groupId: chatId ?? params.groupId,
|
||||
accountId: params.accountId,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -237,6 +253,10 @@ export function resolveWhatsAppGroupToolPolicy(
|
||||
channel: "whatsapp",
|
||||
groupId: params.groupId,
|
||||
accountId: params.accountId,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -248,6 +268,10 @@ export function resolveIMessageGroupToolPolicy(
|
||||
channel: "imessage",
|
||||
groupId: params.groupId,
|
||||
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}`])
|
||||
: 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;
|
||||
}
|
||||
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;
|
||||
return undefined;
|
||||
}
|
||||
@@ -294,7 +334,9 @@ export function resolveSlackGroupToolPolicy(
|
||||
channelName ?? "",
|
||||
normalizedName,
|
||||
].filter(Boolean);
|
||||
let matched: { tools?: GroupToolPolicyConfig } | undefined;
|
||||
let matched:
|
||||
| { tools?: GroupToolPolicyConfig; toolsBySender?: GroupToolPolicyBySenderConfig }
|
||||
| undefined;
|
||||
for (const candidate of candidates) {
|
||||
if (candidate && channels[candidate]) {
|
||||
matched = channels[candidate];
|
||||
@@ -302,6 +344,14 @@ export function resolveSlackGroupToolPolicy(
|
||||
}
|
||||
}
|
||||
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;
|
||||
return undefined;
|
||||
}
|
||||
@@ -314,5 +364,9 @@ export function resolveBlueBubblesGroupToolPolicy(
|
||||
channel: "bluebubbles",
|
||||
groupId: params.groupId,
|
||||
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",
|
||||
"search",
|
||||
"sticker",
|
||||
"sticker-search",
|
||||
"member-info",
|
||||
"role-info",
|
||||
"emoji-list",
|
||||
|
||||
@@ -155,6 +155,10 @@ export type ChannelGroupContext = {
|
||||
groupChannel?: string | null;
|
||||
groupSpace?: string | null;
|
||||
accountId?: string | null;
|
||||
senderId?: string | null;
|
||||
senderName?: string | null;
|
||||
senderUsername?: string | null;
|
||||
senderE164?: string | null;
|
||||
};
|
||||
|
||||
export type ChannelCapabilities = {
|
||||
|
||||
@@ -1,23 +1,52 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
|
||||
const clientMocks = vi.hoisted(() => ({
|
||||
browserSnapshot: vi.fn(async () => ({
|
||||
const gatewayMocks = vi.hoisted(() => ({
|
||||
callGatewayFromCli: vi.fn(async () => ({
|
||||
ok: true,
|
||||
format: "ai",
|
||||
targetId: "t1",
|
||||
url: "https://example.com",
|
||||
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(() => ({
|
||||
loadConfig: vi.fn(() => ({ browser: {} })),
|
||||
}));
|
||||
vi.mock("../config/config.js", () => configMocks);
|
||||
|
||||
const sharedMocks = vi.hoisted(() => ({
|
||||
callBrowserRequest: vi.fn(
|
||||
async (_opts: unknown, params: { path?: string; query?: Record<string, unknown> }) => {
|
||||
const format = params.query?.format === "aria" ? "aria" : "ai";
|
||||
if (format === "aria") {
|
||||
return {
|
||||
ok: true,
|
||||
format: "aria",
|
||||
targetId: "t1",
|
||||
url: "https://example.com",
|
||||
nodes: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
format: "ai",
|
||||
targetId: "t1",
|
||||
url: "https://example.com",
|
||||
snapshot: "ok",
|
||||
};
|
||||
},
|
||||
),
|
||||
}));
|
||||
vi.mock("./browser-cli-shared.js", () => ({
|
||||
callBrowserRequest: sharedMocks.callBrowserRequest,
|
||||
}));
|
||||
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
@@ -37,6 +66,7 @@ describe("browser cli snapshot defaults", () => {
|
||||
configMocks.loadConfig.mockReturnValue({
|
||||
browser: { snapshotDefaults: { mode: "efficient" } },
|
||||
});
|
||||
|
||||
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
|
||||
const program = new Command();
|
||||
const browser = program.command("browser").option("--json", false);
|
||||
@@ -44,26 +74,28 @@ describe("browser cli snapshot defaults", () => {
|
||||
|
||||
await program.parseAsync(["browser", "snapshot"], { from: "user" });
|
||||
|
||||
expect(clientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:18791",
|
||||
expect.objectContaining({
|
||||
format: "ai",
|
||||
mode: "efficient",
|
||||
}),
|
||||
);
|
||||
expect(sharedMocks.callBrowserRequest).toHaveBeenCalled();
|
||||
const [, params] = sharedMocks.callBrowserRequest.mock.calls.at(-1) ?? [];
|
||||
expect(params?.path).toBe("/snapshot");
|
||||
expect(params?.query).toMatchObject({
|
||||
format: "ai",
|
||||
mode: "efficient",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not apply config snapshot defaults to aria snapshots", async () => {
|
||||
configMocks.loadConfig.mockReturnValue({
|
||||
browser: { snapshotDefaults: { mode: "efficient" } },
|
||||
});
|
||||
clientMocks.browserSnapshot.mockResolvedValueOnce({
|
||||
|
||||
gatewayMocks.callGatewayFromCli.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
format: "aria",
|
||||
targetId: "t1",
|
||||
url: "https://example.com",
|
||||
nodes: [],
|
||||
});
|
||||
|
||||
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
|
||||
const program = new Command();
|
||||
const browser = program.command("browser").option("--json", false);
|
||||
@@ -71,8 +103,9 @@ describe("browser cli snapshot defaults", () => {
|
||||
|
||||
await program.parseAsync(["browser", "snapshot", "--format", "aria"], { from: "user" });
|
||||
|
||||
expect(clientMocks.browserSnapshot).toHaveBeenCalled();
|
||||
const [, opts] = clientMocks.browserSnapshot.mock.calls.at(-1) ?? [];
|
||||
expect(opts?.mode).toBeUndefined();
|
||||
expect(sharedMocks.callBrowserRequest).toHaveBeenCalled();
|
||||
const [, params] = sharedMocks.callBrowserRequest.mock.calls.at(-1) ?? [];
|
||||
expect(params?.path).toBe("/snapshot");
|
||||
expect((params?.query as { mode?: unknown } | undefined)?.mode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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(() => ({
|
||||
loadSessionStore: vi.fn().mockReturnValue({
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import { normalizeAccountId } from "../routing/session-key.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 ChannelGroupConfig = {
|
||||
requireMention?: boolean;
|
||||
tools?: GroupToolPolicyConfig;
|
||||
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||
};
|
||||
|
||||
export type ChannelGroupPolicy = {
|
||||
@@ -19,6 +20,65 @@ export type ChannelGroupPolicy = {
|
||||
|
||||
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(
|
||||
cfg: ClawdbotConfig,
|
||||
channel: GroupPolicyChannel,
|
||||
@@ -94,14 +154,32 @@ export function resolveChannelGroupRequireMention(params: {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveChannelGroupToolsPolicy(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
channel: GroupPolicyChannel;
|
||||
groupId?: string | null;
|
||||
accountId?: string | null;
|
||||
}): GroupToolPolicyConfig | undefined {
|
||||
export function resolveChannelGroupToolsPolicy(
|
||||
params: {
|
||||
cfg: ClawdbotConfig;
|
||||
channel: GroupPolicyChannel;
|
||||
groupId?: string | null;
|
||||
accountId?: string | null;
|
||||
} & GroupToolPolicySender,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
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;
|
||||
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;
|
||||
return undefined;
|
||||
}
|
||||
@@ -279,6 +279,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"ui.seamColor": "Accent Color",
|
||||
"ui.assistant.name": "Assistant Name",
|
||||
"ui.assistant.avatar": "Assistant Avatar",
|
||||
"browser.evaluateEnabled": "Browser Evaluate Enabled",
|
||||
"browser.snapshotDefaults": "Browser Snapshot Defaults",
|
||||
"browser.snapshotDefaults.mode": "Browser Snapshot Mode",
|
||||
"browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)",
|
||||
|
||||
@@ -14,6 +14,8 @@ export type BrowserSnapshotDefaults = {
|
||||
};
|
||||
export type BrowserConfig = {
|
||||
enabled?: boolean;
|
||||
/** If false, disable browser act:evaluate (arbitrary JS). Default: true */
|
||||
evaluateEnabled?: boolean;
|
||||
/** Base URL of the CDP endpoint (for remote browsers). Default: loopback CDP on the derived port. */
|
||||
cdpUrl?: string;
|
||||
/** Remote CDP HTTP timeout (ms). Default: 1500. */
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
} from "./types.base.js";
|
||||
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.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 = {
|
||||
/** If false, ignore all incoming Discord DMs. Default: true. */
|
||||
@@ -28,6 +28,7 @@ export type DiscordGuildChannelConfig = {
|
||||
requireMention?: boolean;
|
||||
/** Optional tool policy overrides for this channel. */
|
||||
tools?: GroupToolPolicyConfig;
|
||||
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||
/** If specified, only load these skills for this channel. Omit = all skills; empty = no skills. */
|
||||
skills?: string[];
|
||||
/** If false, disable the bot for this channel. */
|
||||
@@ -45,6 +46,7 @@ export type DiscordGuildEntry = {
|
||||
requireMention?: boolean;
|
||||
/** Optional tool policy overrides for this guild (used when channel override is missing). */
|
||||
tools?: GroupToolPolicyConfig;
|
||||
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
||||
reactionNotifications?: DiscordReactionNotificationMode;
|
||||
users?: Array<string | number>;
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
} from "./types.base.js";
|
||||
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.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 = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
@@ -64,6 +64,7 @@ export type IMessageAccountConfig = {
|
||||
{
|
||||
requireMention?: boolean;
|
||||
tools?: GroupToolPolicyConfig;
|
||||
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||
}
|
||||
>;
|
||||
/** Heartbeat visibility settings for this channel. */
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
} from "./types.base.js";
|
||||
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.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 = {
|
||||
/** Port for the webhook server. Default: 3978. */
|
||||
@@ -24,6 +24,7 @@ export type MSTeamsChannelConfig = {
|
||||
requireMention?: boolean;
|
||||
/** Optional tool policy overrides for this channel. */
|
||||
tools?: GroupToolPolicyConfig;
|
||||
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||
/** Reply style: "thread" replies to the message, "top-level" posts a new message. */
|
||||
replyStyle?: MSTeamsReplyStyle;
|
||||
};
|
||||
@@ -34,6 +35,7 @@ export type MSTeamsTeamConfig = {
|
||||
requireMention?: boolean;
|
||||
/** Default tool policy for channels in this team. */
|
||||
tools?: GroupToolPolicyConfig;
|
||||
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||
/** Default reply style for channels in this team. */
|
||||
replyStyle?: MSTeamsReplyStyle;
|
||||
/** Per-channel overrides. Key is conversation ID (e.g., "19:...@thread.tacv2"). */
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
} from "./types.base.js";
|
||||
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.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 = {
|
||||
/** If false, ignore all incoming Slack DMs. Default: true. */
|
||||
@@ -33,6 +33,7 @@ export type SlackChannelConfig = {
|
||||
requireMention?: boolean;
|
||||
/** Optional tool policy overrides for this channel. */
|
||||
tools?: GroupToolPolicyConfig;
|
||||
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||
/** Allow bot-authored messages to trigger replies (default: false). */
|
||||
allowBots?: boolean;
|
||||
/** Allowlist of users that can invoke the bot in this channel. */
|
||||
|
||||
@@ -9,13 +9,15 @@ import type {
|
||||
} from "./types.base.js";
|
||||
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.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 = {
|
||||
reactions?: boolean;
|
||||
sendMessage?: boolean;
|
||||
deleteMessage?: boolean;
|
||||
editMessage?: boolean;
|
||||
/** Enable sticker actions (send and search). */
|
||||
sticker?: boolean;
|
||||
};
|
||||
|
||||
export type TelegramNetworkConfig = {
|
||||
@@ -146,6 +148,7 @@ export type TelegramGroupConfig = {
|
||||
requireMention?: boolean;
|
||||
/** Optional tool policy overrides for this group. */
|
||||
tools?: GroupToolPolicyConfig;
|
||||
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||
/** If specified, only load these skills for this group (when no topic). Omit = all skills; empty = no skills. */
|
||||
skills?: string[];
|
||||
/** Per-topic configuration (key is message_thread_id as string) */
|
||||
|
||||
@@ -158,6 +158,8 @@ export type GroupToolPolicyConfig = {
|
||||
deny?: string[];
|
||||
};
|
||||
|
||||
export type GroupToolPolicyBySenderConfig = Record<string, GroupToolPolicyConfig>;
|
||||
|
||||
export type ExecToolConfig = {
|
||||
/** Exec host routing (default: sandbox). */
|
||||
host?: "sandbox" | "gateway" | "node";
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
} from "./types.base.js";
|
||||
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.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 = {
|
||||
reactions?: boolean;
|
||||
@@ -70,6 +70,7 @@ export type WhatsAppConfig = {
|
||||
{
|
||||
requireMention?: boolean;
|
||||
tools?: GroupToolPolicyConfig;
|
||||
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||
}
|
||||
>;
|
||||
/** Acknowledgment reaction sent immediately upon message receipt. */
|
||||
@@ -135,6 +136,7 @@ export type WhatsAppAccountConfig = {
|
||||
{
|
||||
requireMention?: boolean;
|
||||
tools?: GroupToolPolicyConfig;
|
||||
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||
}
|
||||
>;
|
||||
/** Acknowledgment reaction sent immediately upon message receipt. */
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
resolveTelegramCustomCommands,
|
||||
} from "./telegram-custom-commands.js";
|
||||
|
||||
const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional();
|
||||
|
||||
const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "allowlist"]);
|
||||
|
||||
const TelegramCapabilitiesSchema = z.union([
|
||||
@@ -47,6 +49,7 @@ export const TelegramGroupSchema = z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
toolsBySender: ToolPolicyBySenderSchema,
|
||||
skills: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
@@ -125,6 +128,7 @@ export const TelegramAccountSchemaBase = z
|
||||
reactions: z.boolean().optional(),
|
||||
sendMessage: z.boolean().optional(),
|
||||
deleteMessage: z.boolean().optional(),
|
||||
sticker: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
@@ -186,6 +190,7 @@ export const DiscordGuildChannelSchema = z
|
||||
allow: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
toolsBySender: ToolPolicyBySenderSchema,
|
||||
skills: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
@@ -199,6 +204,7 @@ export const DiscordGuildSchema = z
|
||||
slug: z.string().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
toolsBySender: ToolPolicyBySenderSchema,
|
||||
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
channels: z.record(z.string(), DiscordGuildChannelSchema.optional()).optional(),
|
||||
@@ -374,6 +380,7 @@ export const SlackChannelSchema = z
|
||||
allow: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
toolsBySender: ToolPolicyBySenderSchema,
|
||||
allowBots: z.boolean().optional(),
|
||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
@@ -584,6 +591,7 @@ export const IMessageAccountSchemaBase = z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
toolsBySender: ToolPolicyBySenderSchema,
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
@@ -640,6 +648,7 @@ const BlueBubblesGroupConfigSchema = z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
toolsBySender: ToolPolicyBySenderSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -699,6 +708,7 @@ export const MSTeamsChannelSchema = z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
toolsBySender: ToolPolicyBySenderSchema,
|
||||
replyStyle: MSTeamsReplyStyleSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -707,6 +717,7 @@ export const MSTeamsTeamSchema = z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
toolsBySender: ToolPolicyBySenderSchema,
|
||||
replyStyle: MSTeamsReplyStyleSchema.optional(),
|
||||
channels: z.record(z.string(), MSTeamsChannelSchema.optional()).optional(),
|
||||
})
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
import { ToolPolicySchema } from "./zod-schema.agent-runtime.js";
|
||||
import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
|
||||
|
||||
const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional();
|
||||
|
||||
export const WhatsAppAccountSchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
@@ -41,6 +43,7 @@ export const WhatsAppAccountSchema = z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
toolsBySender: ToolPolicyBySenderSchema,
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
@@ -105,6 +108,7 @@ export const WhatsAppConfigSchema = z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
toolsBySender: ToolPolicyBySenderSchema,
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
@@ -134,6 +134,7 @@ export const ClawdbotSchema = z
|
||||
browser: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
evaluateEnabled: z.boolean().optional(),
|
||||
cdpUrl: z.string().optional(),
|
||||
remoteCdpTimeoutMs: z.number().int().nonnegative().optional(),
|
||||
remoteCdpHandshakeTimeoutMs: z.number().int().nonnegative().optional(),
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { HookEligibilityContext, HookEntry } from "./types.js";
|
||||
|
||||
const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
|
||||
"browser.enabled": true,
|
||||
"browser.evaluateEnabled": true,
|
||||
"workspace.dir": true,
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
|
||||
"thread-reply": "to",
|
||||
search: "none",
|
||||
sticker: "to",
|
||||
"sticker-search": "none",
|
||||
"member-info": "none",
|
||||
"role-info": "none",
|
||||
"emoji-list": "none",
|
||||
|
||||
@@ -412,6 +412,39 @@ async function resolveAutoEntries(params: {
|
||||
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: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentDir?: string;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DEFAULT_GEMINI_EMBEDDING_MODEL } from "./embeddings-gemini.js";
|
||||
|
||||
vi.mock("../agents/model-auth.js", () => ({
|
||||
resolveApiKeyForProvider: vi.fn(),
|
||||
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 () => {
|
||||
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 authModule = await import("../agents/model-auth.js");
|
||||
vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => {
|
||||
@@ -214,6 +223,44 @@ describe("embedding provider auto selection", () => {
|
||||
|
||||
expect(result.requestedProvider).toBe("auto");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { buildPairingReply } from "./pairing-messages.js";
|
||||
|
||||
describe("buildPairingReply", () => {
|
||||
let previousProfile: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
previousProfile = process.env.CLAWDBOT_PROFILE;
|
||||
process.env.CLAWDBOT_PROFILE = "isolated";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousProfile === undefined) {
|
||||
delete process.env.CLAWDBOT_PROFILE;
|
||||
return;
|
||||
}
|
||||
process.env.CLAWDBOT_PROFILE = previousProfile;
|
||||
});
|
||||
|
||||
const cases = [
|
||||
{
|
||||
channel: "discord",
|
||||
|
||||
@@ -81,6 +81,7 @@ export type {
|
||||
DmConfig,
|
||||
GroupPolicy,
|
||||
GroupToolPolicyConfig,
|
||||
GroupToolPolicyBySenderConfig,
|
||||
MarkdownConfig,
|
||||
MarkdownTableMode,
|
||||
GoogleChatAccountConfig,
|
||||
@@ -121,6 +122,7 @@ export { resolveAckReaction } from "../agents/identity.js";
|
||||
export type { ReplyPayload } from "../auto-reply/types.js";
|
||||
export type { ChunkMode } from "../auto-reply/chunk.js";
|
||||
export { SILENT_REPLY_TOKEN, isSilentReplyText } from "../auto-reply/tokens.js";
|
||||
export { resolveToolsBySender } from "../config/group-policy.js";
|
||||
export {
|
||||
buildPendingHistoryContextFromMap,
|
||||
clearHistoryEntries,
|
||||
|
||||
@@ -112,11 +112,19 @@ export const registerTelegramHandlers = ({
|
||||
const captionMsg = entry.messages.find((m) => m.msg.caption || m.msg.text);
|
||||
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) {
|
||||
const media = await resolveMedia(ctx, mediaMaxBytes, opts.token, opts.proxyFetch);
|
||||
if (media) {
|
||||
allMedia.push({ path: media.path, contentType: media.contentType });
|
||||
allMedia.push({
|
||||
path: media.path,
|
||||
contentType: media.contentType,
|
||||
stickerMetadata: media.stickerMetadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,7 +603,24 @@ export const registerTelegramHandlers = ({
|
||||
}
|
||||
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 conversationKey =
|
||||
resolvedThreadId != null ? `${chatId}:topic:${resolvedThreadId}` : String(chatId);
|
||||
|
||||
@@ -49,7 +49,17 @@ import {
|
||||
import { upsertTelegramPairingRequest } from "./pairing-store.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 = {
|
||||
forceWasMentioned?: boolean;
|
||||
@@ -302,6 +312,18 @@ export const buildTelegramMessageContext = async ({
|
||||
else if (msg.video) placeholder = "<media:video>";
|
||||
else if (msg.audio || msg.voice) placeholder = "<media:audio>";
|
||||
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 stickerCacheHit = Boolean(cachedStickerDescription);
|
||||
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 locationText = locationData ? formatLocationText(locationData) : undefined;
|
||||
@@ -525,15 +547,26 @@ export const buildTelegramMessageContext = async ({
|
||||
ForwardedDate: forwardOrigin?.date ? forwardOrigin.date * 1000 : undefined,
|
||||
Timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
|
||||
MediaPath: allMedia[0]?.path,
|
||||
MediaType: allMedia[0]?.contentType,
|
||||
MediaUrl: allMedia[0]?.path,
|
||||
MediaPaths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
|
||||
MediaUrls: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
|
||||
MediaTypes:
|
||||
allMedia.length > 0
|
||||
// Filter out cached stickers from media - their description is already in the message body
|
||||
MediaPath: stickerCacheHit ? undefined : allMedia[0]?.path,
|
||||
MediaType: stickerCacheHit ? undefined : allMedia[0]?.contentType,
|
||||
MediaUrl: stickerCacheHit ? undefined : allMedia[0]?.path,
|
||||
MediaPaths: stickerCacheHit
|
||||
? undefined
|
||||
: 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[])
|
||||
: undefined,
|
||||
Sticker: allMedia[0]?.stickerMetadata,
|
||||
...(locationData ? toLocationContext(locationData) : undefined),
|
||||
CommandAuthorized: commandAuthorized,
|
||||
MessageThreadId: resolvedThreadId,
|
||||
|
||||
@@ -12,6 +12,8 @@ import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||
import { deliverReplies } from "./bot/delivery.js";
|
||||
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
|
||||
import { createTelegramDraftStream } from "./draft-stream.js";
|
||||
import { cacheSticker, describeStickerImage } from "./sticker-cache.js";
|
||||
import { resolveAgentDir } from "../agents/agent-scope.js";
|
||||
|
||||
export const dispatchTelegramMessage = async ({
|
||||
context,
|
||||
@@ -128,6 +130,50 @@ export const dispatchTelegramMessage = async ({
|
||||
});
|
||||
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 && !sticker.cachedDescription && ctxPayload.MediaPath) {
|
||||
const agentDir = resolveAgentDir(cfg, route.agentId);
|
||||
const 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}`;
|
||||
|
||||
// Update context to use description instead of image
|
||||
sticker.cachedDescription = description;
|
||||
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({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
@@ -139,6 +185,7 @@ export const dispatchTelegramMessage = async ({
|
||||
await flushDraft();
|
||||
draftStream?.stop();
|
||||
}
|
||||
|
||||
await deliverReplies({
|
||||
replies: [payload],
|
||||
chatId: String(chatId),
|
||||
|
||||
@@ -7,6 +7,9 @@ const middlewareUseSpy = vi.fn();
|
||||
const onSpy = vi.fn();
|
||||
const stopSpy = vi.fn();
|
||||
const sendChatActionSpy = vi.fn();
|
||||
const cacheStickerSpy = vi.fn();
|
||||
const getCachedStickerSpy = vi.fn();
|
||||
const describeStickerImageSpy = vi.fn();
|
||||
|
||||
type ApiStub = {
|
||||
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", () => ({
|
||||
readTelegramAllowFromStore: vi.fn(async () => [] as string[]),
|
||||
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", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -21,7 +21,8 @@ import { loadWebMedia } from "../../web/media.js";
|
||||
import { buildInlineKeyboard } from "../send.js";
|
||||
import { resolveTelegramVoiceSend } from "../voice.js";
|
||||
import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js";
|
||||
import type { TelegramContext } from "./types.js";
|
||||
import type { StickerMetadata, TelegramContext } from "./types.js";
|
||||
import { cacheSticker, getCachedSticker } from "../sticker-cache.js";
|
||||
|
||||
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
|
||||
const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
|
||||
@@ -261,8 +262,91 @@ export async function resolveMedia(
|
||||
maxBytes: number,
|
||||
token: string,
|
||||
proxyFetch?: typeof fetch,
|
||||
): Promise<{ path: string; contentType?: string; placeholder: string } | null> {
|
||||
): Promise<{
|
||||
path: string;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
stickerMetadata?: StickerMetadata;
|
||||
} | null> {
|
||||
const msg = ctx.message;
|
||||
|
||||
// Handle stickers separately - only static stickers (WEBP) are supported
|
||||
if (msg.sticker) {
|
||||
const sticker = msg.sticker;
|
||||
// Skip animated (TGS) and video (WEBM) stickers - only static WEBP supported
|
||||
if (sticker.is_animated || sticker.is_video) {
|
||||
logVerbose("telegram: skipping animated/video sticker (only static stickers supported)");
|
||||
return null;
|
||||
}
|
||||
if (!sticker.file_id) return null;
|
||||
|
||||
try {
|
||||
const file = await ctx.getFile();
|
||||
if (!file.file_path) {
|
||||
logVerbose("telegram: getFile returned no file_path for sticker");
|
||||
return null;
|
||||
}
|
||||
const fetchImpl = proxyFetch ?? globalThis.fetch;
|
||||
if (!fetchImpl) {
|
||||
logVerbose("telegram: fetch not available for sticker download");
|
||||
return null;
|
||||
}
|
||||
const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
|
||||
const fetched = await fetchRemoteMedia({
|
||||
url,
|
||||
fetchImpl,
|
||||
filePathHint: file.file_path,
|
||||
});
|
||||
const saved = await saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes);
|
||||
|
||||
// Check sticker cache for existing description
|
||||
const cached = sticker.file_unique_id ? getCachedSticker(sticker.file_unique_id) : null;
|
||||
if (cached) {
|
||||
logVerbose(`telegram: sticker cache hit for ${sticker.file_unique_id}`);
|
||||
const fileId = sticker.file_id ?? cached.fileId;
|
||||
const emoji = sticker.emoji ?? cached.emoji;
|
||||
const setName = sticker.set_name ?? cached.setName;
|
||||
if (fileId !== cached.fileId || emoji !== cached.emoji || setName !== cached.setName) {
|
||||
// Refresh cached sticker metadata on hits so sends/searches use latest file_id.
|
||||
cacheSticker({
|
||||
...cached,
|
||||
fileId,
|
||||
emoji,
|
||||
setName,
|
||||
});
|
||||
}
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "<media:sticker>",
|
||||
stickerMetadata: {
|
||||
emoji,
|
||||
setName,
|
||||
fileId,
|
||||
fileUniqueId: sticker.file_unique_id,
|
||||
cachedDescription: cached.description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Cache miss - return metadata for vision processing
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "<media:sticker>",
|
||||
stickerMetadata: {
|
||||
emoji: sticker.emoji ?? undefined,
|
||||
setName: sticker.set_name ?? undefined,
|
||||
fileId: sticker.file_id,
|
||||
fileUniqueId: sticker.file_unique_id,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
logVerbose(`telegram: failed to process sticker: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const m =
|
||||
msg.photo?.[msg.photo.length - 1] ?? msg.video ?? msg.document ?? msg.audio ?? msg.voice;
|
||||
if (!m?.file_id) return null;
|
||||
|
||||
@@ -67,3 +67,17 @@ export interface TelegramVenue {
|
||||
google_place_id?: string;
|
||||
google_place_type?: string;
|
||||
}
|
||||
|
||||
/** Telegram sticker metadata for context enrichment. */
|
||||
export interface StickerMetadata {
|
||||
/** Emoji associated with the sticker. */
|
||||
emoji?: string;
|
||||
/** Name of the sticker set the sticker belongs to. */
|
||||
setName?: string;
|
||||
/** Telegram file_id for sending the sticker back. */
|
||||
fileId?: string;
|
||||
/** Stable file_unique_id for cache deduplication. */
|
||||
fileUniqueId?: string;
|
||||
/** Cached description from previous vision processing (skip re-processing if present). */
|
||||
cachedDescription?: string;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ const { botApi, botCtorSpy } = vi.hoisted(() => ({
|
||||
botApi: {
|
||||
sendMessage: vi.fn(),
|
||||
setMessageReaction: vi.fn(),
|
||||
sendSticker: vi.fn(),
|
||||
},
|
||||
botCtorSpy: vi.fn(),
|
||||
}));
|
||||
@@ -43,7 +44,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { buildInlineKeyboard, sendMessageTelegram } from "./send.js";
|
||||
import { buildInlineKeyboard, sendMessageTelegram, sendStickerTelegram } from "./send.js";
|
||||
|
||||
describe("buildInlineKeyboard", () => {
|
||||
it("returns undefined for empty input", () => {
|
||||
@@ -566,3 +567,183 @@ describe("sendMessageTelegram", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendStickerTelegram", () => {
|
||||
beforeEach(() => {
|
||||
loadConfig.mockReturnValue({});
|
||||
botApi.sendSticker.mockReset();
|
||||
botCtorSpy.mockReset();
|
||||
});
|
||||
|
||||
it("sends a sticker by file_id", async () => {
|
||||
const chatId = "123";
|
||||
const fileId = "CAACAgIAAxkBAAI...sticker_file_id";
|
||||
const sendSticker = vi.fn().mockResolvedValue({
|
||||
message_id: 100,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendSticker } as unknown as {
|
||||
sendSticker: typeof sendSticker;
|
||||
};
|
||||
|
||||
const res = await sendStickerTelegram(chatId, fileId, {
|
||||
token: "tok",
|
||||
api,
|
||||
});
|
||||
|
||||
expect(sendSticker).toHaveBeenCalledWith(chatId, fileId, undefined);
|
||||
expect(res.messageId).toBe("100");
|
||||
expect(res.chatId).toBe(chatId);
|
||||
});
|
||||
|
||||
it("throws error when fileId is empty", async () => {
|
||||
await expect(sendStickerTelegram("123", "", { token: "tok" })).rejects.toThrow(
|
||||
/file_id is required/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws error when fileId is whitespace only", async () => {
|
||||
await expect(sendStickerTelegram("123", " ", { token: "tok" })).rejects.toThrow(
|
||||
/file_id is required/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("includes message_thread_id for forum topic messages", async () => {
|
||||
const chatId = "-1001234567890";
|
||||
const fileId = "CAACAgIAAxkBAAI...sticker_file_id";
|
||||
const sendSticker = vi.fn().mockResolvedValue({
|
||||
message_id: 101,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendSticker } as unknown as {
|
||||
sendSticker: typeof sendSticker;
|
||||
};
|
||||
|
||||
await sendStickerTelegram(chatId, fileId, {
|
||||
token: "tok",
|
||||
api,
|
||||
messageThreadId: 271,
|
||||
});
|
||||
|
||||
expect(sendSticker).toHaveBeenCalledWith(chatId, fileId, {
|
||||
message_thread_id: 271,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes reply_to_message_id for threaded replies", async () => {
|
||||
const chatId = "123";
|
||||
const fileId = "CAACAgIAAxkBAAI...sticker_file_id";
|
||||
const sendSticker = vi.fn().mockResolvedValue({
|
||||
message_id: 102,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendSticker } as unknown as {
|
||||
sendSticker: typeof sendSticker;
|
||||
};
|
||||
|
||||
await sendStickerTelegram(chatId, fileId, {
|
||||
token: "tok",
|
||||
api,
|
||||
replyToMessageId: 500,
|
||||
});
|
||||
|
||||
expect(sendSticker).toHaveBeenCalledWith(chatId, fileId, {
|
||||
reply_to_message_id: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes both thread and reply params for forum topic replies", async () => {
|
||||
const chatId = "-1001234567890";
|
||||
const fileId = "CAACAgIAAxkBAAI...sticker_file_id";
|
||||
const sendSticker = vi.fn().mockResolvedValue({
|
||||
message_id: 103,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendSticker } as unknown as {
|
||||
sendSticker: typeof sendSticker;
|
||||
};
|
||||
|
||||
await sendStickerTelegram(chatId, fileId, {
|
||||
token: "tok",
|
||||
api,
|
||||
messageThreadId: 271,
|
||||
replyToMessageId: 500,
|
||||
});
|
||||
|
||||
expect(sendSticker).toHaveBeenCalledWith(chatId, fileId, {
|
||||
message_thread_id: 271,
|
||||
reply_to_message_id: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes chat ids with internal prefixes", async () => {
|
||||
const sendSticker = vi.fn().mockResolvedValue({
|
||||
message_id: 104,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const api = { sendSticker } as unknown as {
|
||||
sendSticker: typeof sendSticker;
|
||||
};
|
||||
|
||||
await sendStickerTelegram("telegram:123", "fileId123", {
|
||||
token: "tok",
|
||||
api,
|
||||
});
|
||||
|
||||
expect(sendSticker).toHaveBeenCalledWith("123", "fileId123", undefined);
|
||||
});
|
||||
|
||||
it("parses message_thread_id from recipient string (telegram:group:...:topic:...)", async () => {
|
||||
const chatId = "-1001234567890";
|
||||
const sendSticker = vi.fn().mockResolvedValue({
|
||||
message_id: 105,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendSticker } as unknown as {
|
||||
sendSticker: typeof sendSticker;
|
||||
};
|
||||
|
||||
await sendStickerTelegram(`telegram:group:${chatId}:topic:271`, "fileId123", {
|
||||
token: "tok",
|
||||
api,
|
||||
});
|
||||
|
||||
expect(sendSticker).toHaveBeenCalledWith(chatId, "fileId123", {
|
||||
message_thread_id: 271,
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps chat-not-found with actionable context", async () => {
|
||||
const chatId = "123";
|
||||
const err = new Error("400: Bad Request: chat not found");
|
||||
const sendSticker = vi.fn().mockRejectedValue(err);
|
||||
const api = { sendSticker } as unknown as {
|
||||
sendSticker: typeof sendSticker;
|
||||
};
|
||||
|
||||
await expect(sendStickerTelegram(chatId, "fileId123", { token: "tok", api })).rejects.toThrow(
|
||||
/chat not found/i,
|
||||
);
|
||||
await expect(sendStickerTelegram(chatId, "fileId123", { token: "tok", api })).rejects.toThrow(
|
||||
/chat_id=123/,
|
||||
);
|
||||
});
|
||||
|
||||
it("trims whitespace from fileId", async () => {
|
||||
const chatId = "123";
|
||||
const sendSticker = vi.fn().mockResolvedValue({
|
||||
message_id: 106,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendSticker } as unknown as {
|
||||
sendSticker: typeof sendSticker;
|
||||
};
|
||||
|
||||
await sendStickerTelegram(chatId, " fileId123 ", {
|
||||
token: "tok",
|
||||
api,
|
||||
});
|
||||
|
||||
expect(sendSticker).toHaveBeenCalledWith(chatId, "fileId123", undefined);
|
||||
});
|
||||
});
|
||||
@@ -619,3 +619,96 @@ function inferFilename(kind: ReturnType<typeof mediaKindFromMime>) {
|
||||
return "file.bin";
|
||||
}
|
||||
}
|
||||
|
||||
type TelegramStickerOpts = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
verbose?: boolean;
|
||||
api?: Bot["api"];
|
||||
retry?: RetryConfig;
|
||||
/** Message ID to reply to (for threading) */
|
||||
replyToMessageId?: number;
|
||||
/** Forum topic thread ID (for forum supergroups) */
|
||||
messageThreadId?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a sticker to a Telegram chat by file_id.
|
||||
* @param to - Chat ID or username (e.g., "123456789" or "@username")
|
||||
* @param fileId - Telegram file_id of the sticker to send
|
||||
* @param opts - Optional configuration
|
||||
*/
|
||||
export async function sendStickerTelegram(
|
||||
to: string,
|
||||
fileId: string,
|
||||
opts: TelegramStickerOpts = {},
|
||||
): Promise<TelegramSendResult> {
|
||||
if (!fileId?.trim()) {
|
||||
throw new Error("Telegram sticker file_id is required");
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const account = resolveTelegramAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = resolveToken(opts.token, account);
|
||||
const target = parseTelegramTarget(to);
|
||||
const chatId = normalizeChatId(target.chatId);
|
||||
const client = resolveTelegramClientOptions(account);
|
||||
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
|
||||
|
||||
const messageThreadId =
|
||||
opts.messageThreadId != null ? opts.messageThreadId : target.messageThreadId;
|
||||
const threadIdParams = buildTelegramThreadParams(messageThreadId);
|
||||
const threadParams: Record<string, number> = threadIdParams ? { ...threadIdParams } : {};
|
||||
if (opts.replyToMessageId != null) {
|
||||
threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId);
|
||||
}
|
||||
const hasThreadParams = Object.keys(threadParams).length > 0;
|
||||
|
||||
const request = createTelegramRetryRunner({
|
||||
retry: opts.retry,
|
||||
configRetry: account.config.retry,
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
const logHttpError = createTelegramHttpLogger(cfg);
|
||||
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
|
||||
request(fn, label).catch((err) => {
|
||||
logHttpError(label ?? "request", err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
const wrapChatNotFound = (err: unknown) => {
|
||||
if (!/400: Bad Request: chat not found/i.test(formatErrorMessage(err))) return err;
|
||||
return new Error(
|
||||
[
|
||||
`Telegram send failed: chat not found (chat_id=${chatId}).`,
|
||||
"Likely: bot not started in DM, bot removed from group/channel, group migrated (new -100… id), or wrong bot token.",
|
||||
`Input was: ${JSON.stringify(to)}.`,
|
||||
].join(" "),
|
||||
);
|
||||
};
|
||||
|
||||
const stickerParams = hasThreadParams ? threadParams : undefined;
|
||||
|
||||
const result = await requestWithDiag(
|
||||
() => api.sendSticker(chatId, fileId.trim(), stickerParams),
|
||||
"sticker",
|
||||
).catch((err) => {
|
||||
throw wrapChatNotFound(err);
|
||||
});
|
||||
|
||||
const messageId = String(result?.message_id ?? "unknown");
|
||||
const resolvedChatId = String(result?.chat?.id ?? chatId);
|
||||
if (result?.message_id) {
|
||||
recordSentMessage(chatId, result.message_id);
|
||||
}
|
||||
recordChannelActivity({
|
||||
channel: "telegram",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
return { messageId, chatId: resolvedChatId };
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cacheSticker,
|
||||
getAllCachedStickers,
|
||||
getCachedSticker,
|
||||
getCacheStats,
|
||||
searchStickers,
|
||||
} from "./sticker-cache.js";
|
||||
|
||||
// Mock the state directory to use a temp location
|
||||
vi.mock("../config/paths.js", () => ({
|
||||
STATE_DIR_CLAWDBOT: "/tmp/clawdbot-test-sticker-cache",
|
||||
}));
|
||||
|
||||
const TEST_CACHE_DIR = "/tmp/clawdbot-test-sticker-cache/telegram";
|
||||
const TEST_CACHE_FILE = path.join(TEST_CACHE_DIR, "sticker-cache.json");
|
||||
|
||||
describe("sticker-cache", () => {
|
||||
beforeEach(() => {
|
||||
// Clean up before each test
|
||||
if (fs.existsSync(TEST_CACHE_FILE)) {
|
||||
fs.unlinkSync(TEST_CACHE_FILE);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up after each test
|
||||
if (fs.existsSync(TEST_CACHE_FILE)) {
|
||||
fs.unlinkSync(TEST_CACHE_FILE);
|
||||
}
|
||||
});
|
||||
|
||||
describe("getCachedSticker", () => {
|
||||
it("returns null for unknown ID", () => {
|
||||
const result = getCachedSticker("unknown-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns cached sticker after cacheSticker", () => {
|
||||
const sticker = {
|
||||
fileId: "file123",
|
||||
fileUniqueId: "unique123",
|
||||
emoji: "🎉",
|
||||
setName: "TestPack",
|
||||
description: "A party popper emoji sticker",
|
||||
cachedAt: "2026-01-26T12:00:00.000Z",
|
||||
};
|
||||
|
||||
cacheSticker(sticker);
|
||||
const result = getCachedSticker("unique123");
|
||||
|
||||
expect(result).toEqual(sticker);
|
||||
});
|
||||
|
||||
it("returns null after cache is cleared", () => {
|
||||
const sticker = {
|
||||
fileId: "file123",
|
||||
fileUniqueId: "unique123",
|
||||
description: "test",
|
||||
cachedAt: "2026-01-26T12:00:00.000Z",
|
||||
};
|
||||
|
||||
cacheSticker(sticker);
|
||||
expect(getCachedSticker("unique123")).not.toBeNull();
|
||||
|
||||
// Manually clear the cache file
|
||||
fs.unlinkSync(TEST_CACHE_FILE);
|
||||
|
||||
expect(getCachedSticker("unique123")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cacheSticker", () => {
|
||||
it("adds entry to cache", () => {
|
||||
const sticker = {
|
||||
fileId: "file456",
|
||||
fileUniqueId: "unique456",
|
||||
description: "A cute fox waving",
|
||||
cachedAt: "2026-01-26T12:00:00.000Z",
|
||||
};
|
||||
|
||||
cacheSticker(sticker);
|
||||
|
||||
const all = getAllCachedStickers();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0]).toEqual(sticker);
|
||||
});
|
||||
|
||||
it("updates existing entry", () => {
|
||||
const original = {
|
||||
fileId: "file789",
|
||||
fileUniqueId: "unique789",
|
||||
description: "Original description",
|
||||
cachedAt: "2026-01-26T12:00:00.000Z",
|
||||
};
|
||||
const updated = {
|
||||
fileId: "file789-new",
|
||||
fileUniqueId: "unique789",
|
||||
description: "Updated description",
|
||||
cachedAt: "2026-01-26T13:00:00.000Z",
|
||||
};
|
||||
|
||||
cacheSticker(original);
|
||||
cacheSticker(updated);
|
||||
|
||||
const result = getCachedSticker("unique789");
|
||||
expect(result?.description).toBe("Updated description");
|
||||
expect(result?.fileId).toBe("file789-new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchStickers", () => {
|
||||
beforeEach(() => {
|
||||
// Seed cache with test stickers
|
||||
cacheSticker({
|
||||
fileId: "fox1",
|
||||
fileUniqueId: "fox-unique-1",
|
||||
emoji: "🦊",
|
||||
setName: "CuteFoxes",
|
||||
description: "A cute orange fox waving hello",
|
||||
cachedAt: "2026-01-26T10:00:00.000Z",
|
||||
});
|
||||
cacheSticker({
|
||||
fileId: "fox2",
|
||||
fileUniqueId: "fox-unique-2",
|
||||
emoji: "🦊",
|
||||
setName: "CuteFoxes",
|
||||
description: "A fox sleeping peacefully",
|
||||
cachedAt: "2026-01-26T11:00:00.000Z",
|
||||
});
|
||||
cacheSticker({
|
||||
fileId: "cat1",
|
||||
fileUniqueId: "cat-unique-1",
|
||||
emoji: "🐱",
|
||||
setName: "FunnyCats",
|
||||
description: "A cat sitting on a keyboard",
|
||||
cachedAt: "2026-01-26T12:00:00.000Z",
|
||||
});
|
||||
cacheSticker({
|
||||
fileId: "dog1",
|
||||
fileUniqueId: "dog-unique-1",
|
||||
emoji: "🐶",
|
||||
setName: "GoodBoys",
|
||||
description: "A golden retriever playing fetch",
|
||||
cachedAt: "2026-01-26T13:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("finds stickers by description substring", () => {
|
||||
const results = searchStickers("fox");
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.every((s) => s.description.toLowerCase().includes("fox"))).toBe(true);
|
||||
});
|
||||
|
||||
it("finds stickers by emoji", () => {
|
||||
const results = searchStickers("🦊");
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.every((s) => s.emoji === "🦊")).toBe(true);
|
||||
});
|
||||
|
||||
it("finds stickers by set name", () => {
|
||||
const results = searchStickers("CuteFoxes");
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.every((s) => s.setName === "CuteFoxes")).toBe(true);
|
||||
});
|
||||
|
||||
it("respects limit parameter", () => {
|
||||
const results = searchStickers("fox", 1);
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ranks exact matches higher", () => {
|
||||
// "waving" appears in "fox waving hello" - should be ranked first
|
||||
const results = searchStickers("waving");
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.fileUniqueId).toBe("fox-unique-1");
|
||||
});
|
||||
|
||||
it("returns empty array for no matches", () => {
|
||||
const results = searchStickers("elephant");
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is case insensitive", () => {
|
||||
const results = searchStickers("FOX");
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("matches multiple words", () => {
|
||||
const results = searchStickers("cat keyboard");
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.fileUniqueId).toBe("cat-unique-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllCachedStickers", () => {
|
||||
it("returns empty array when cache is empty", () => {
|
||||
const result = getAllCachedStickers();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all cached stickers", () => {
|
||||
cacheSticker({
|
||||
fileId: "a",
|
||||
fileUniqueId: "a-unique",
|
||||
description: "Sticker A",
|
||||
cachedAt: "2026-01-26T10:00:00.000Z",
|
||||
});
|
||||
cacheSticker({
|
||||
fileId: "b",
|
||||
fileUniqueId: "b-unique",
|
||||
description: "Sticker B",
|
||||
cachedAt: "2026-01-26T11:00:00.000Z",
|
||||
});
|
||||
|
||||
const result = getAllCachedStickers();
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCacheStats", () => {
|
||||
it("returns count 0 when cache is empty", () => {
|
||||
const stats = getCacheStats();
|
||||
expect(stats.count).toBe(0);
|
||||
expect(stats.oldestAt).toBeUndefined();
|
||||
expect(stats.newestAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns correct stats with cached stickers", () => {
|
||||
cacheSticker({
|
||||
fileId: "old",
|
||||
fileUniqueId: "old-unique",
|
||||
description: "Old sticker",
|
||||
cachedAt: "2026-01-20T10:00:00.000Z",
|
||||
});
|
||||
cacheSticker({
|
||||
fileId: "new",
|
||||
fileUniqueId: "new-unique",
|
||||
description: "New sticker",
|
||||
cachedAt: "2026-01-26T10:00:00.000Z",
|
||||
});
|
||||
cacheSticker({
|
||||
fileId: "mid",
|
||||
fileUniqueId: "mid-unique",
|
||||
description: "Middle sticker",
|
||||
cachedAt: "2026-01-23T10:00:00.000Z",
|
||||
});
|
||||
|
||||
const stats = getCacheStats();
|
||||
expect(stats.count).toBe(3);
|
||||
expect(stats.oldestAt).toBe("2026-01-20T10:00:00.000Z");
|
||||
expect(stats.newestAt).toBe("2026-01-26T10:00:00.000Z");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { STATE_DIR_CLAWDBOT } from "../config/paths.js";
|
||||
import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import {
|
||||
findModelInCatalog,
|
||||
loadModelCatalog,
|
||||
modelSupportsVision,
|
||||
} from "../agents/model-catalog.js";
|
||||
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
|
||||
import { resolveAutoImageModel } from "../media-understanding/runner.js";
|
||||
|
||||
const CACHE_FILE = path.join(STATE_DIR_CLAWDBOT, "telegram", "sticker-cache.json");
|
||||
const CACHE_VERSION = 1;
|
||||
|
||||
export interface CachedSticker {
|
||||
fileId: string;
|
||||
fileUniqueId: string;
|
||||
emoji?: string;
|
||||
setName?: string;
|
||||
description: string;
|
||||
cachedAt: string;
|
||||
receivedFrom?: string;
|
||||
}
|
||||
|
||||
interface StickerCache {
|
||||
version: number;
|
||||
stickers: Record<string, CachedSticker>;
|
||||
}
|
||||
|
||||
function loadCache(): StickerCache {
|
||||
const data = loadJsonFile(CACHE_FILE);
|
||||
if (!data || typeof data !== "object") {
|
||||
return { version: CACHE_VERSION, stickers: {} };
|
||||
}
|
||||
const cache = data as StickerCache;
|
||||
if (cache.version !== CACHE_VERSION) {
|
||||
// Future: handle migration if needed
|
||||
return { version: CACHE_VERSION, stickers: {} };
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function saveCache(cache: StickerCache): void {
|
||||
saveJsonFile(CACHE_FILE, cache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a cached sticker by its unique ID.
|
||||
*/
|
||||
export function getCachedSticker(fileUniqueId: string): CachedSticker | null {
|
||||
const cache = loadCache();
|
||||
return cache.stickers[fileUniqueId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a sticker in the cache.
|
||||
*/
|
||||
export function cacheSticker(sticker: CachedSticker): void {
|
||||
const cache = loadCache();
|
||||
cache.stickers[sticker.fileUniqueId] = sticker;
|
||||
saveCache(cache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search cached stickers by text query (fuzzy match on description + emoji + setName).
|
||||
*/
|
||||
export function searchStickers(query: string, limit = 10): CachedSticker[] {
|
||||
const cache = loadCache();
|
||||
const queryLower = query.toLowerCase();
|
||||
const results: Array<{ sticker: CachedSticker; score: number }> = [];
|
||||
|
||||
for (const sticker of Object.values(cache.stickers)) {
|
||||
let score = 0;
|
||||
const descLower = sticker.description.toLowerCase();
|
||||
|
||||
// Exact substring match in description
|
||||
if (descLower.includes(queryLower)) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
// Word-level matching
|
||||
const queryWords = queryLower.split(/\s+/).filter(Boolean);
|
||||
const descWords = descLower.split(/\s+/);
|
||||
for (const qWord of queryWords) {
|
||||
if (descWords.some((dWord) => dWord.includes(qWord))) {
|
||||
score += 5;
|
||||
}
|
||||
}
|
||||
|
||||
// Emoji match
|
||||
if (sticker.emoji && query.includes(sticker.emoji)) {
|
||||
score += 8;
|
||||
}
|
||||
|
||||
// Set name match
|
||||
if (sticker.setName?.toLowerCase().includes(queryLower)) {
|
||||
score += 3;
|
||||
}
|
||||
|
||||
if (score > 0) {
|
||||
results.push({ sticker, score });
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map((r) => r.sticker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached stickers (for debugging/listing).
|
||||
*/
|
||||
export function getAllCachedStickers(): CachedSticker[] {
|
||||
const cache = loadCache();
|
||||
return Object.values(cache.stickers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics.
|
||||
*/
|
||||
export function getCacheStats(): { count: number; oldestAt?: string; newestAt?: string } {
|
||||
const cache = loadCache();
|
||||
const stickers = Object.values(cache.stickers);
|
||||
if (stickers.length === 0) {
|
||||
return { count: 0 };
|
||||
}
|
||||
const sorted = [...stickers].sort(
|
||||
(a, b) => new Date(a.cachedAt).getTime() - new Date(b.cachedAt).getTime(),
|
||||
);
|
||||
return {
|
||||
count: stickers.length,
|
||||
oldestAt: sorted[0]?.cachedAt,
|
||||
newestAt: sorted[sorted.length - 1]?.cachedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const STICKER_DESCRIPTION_PROMPT =
|
||||
"Describe this sticker image in 1-2 sentences. Focus on what the sticker depicts (character, object, action, emotion). Be concise and objective.";
|
||||
|
||||
export interface DescribeStickerParams {
|
||||
imagePath: string;
|
||||
cfg: ClawdbotConfig;
|
||||
agentDir?: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe a sticker image using vision API.
|
||||
* Auto-detects an available vision provider based on configured API keys.
|
||||
* Returns null if no vision provider is available.
|
||||
*/
|
||||
export async function describeStickerImage(params: DescribeStickerParams): Promise<string | null> {
|
||||
const { imagePath, cfg, agentDir, agentId } = params;
|
||||
|
||||
const defaultModel = resolveDefaultModelForAgent({ cfg, agentId });
|
||||
let activeModel = undefined as { provider: string; model: string } | undefined;
|
||||
try {
|
||||
const catalog = await loadModelCatalog({ config: cfg });
|
||||
const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model);
|
||||
if (modelSupportsVision(entry)) {
|
||||
activeModel = { provider: defaultModel.provider, model: defaultModel.model };
|
||||
}
|
||||
} catch {
|
||||
// Ignore catalog failures; fall back to auto selection.
|
||||
}
|
||||
|
||||
const resolved = await resolveAutoImageModel({
|
||||
cfg,
|
||||
agentDir,
|
||||
activeModel,
|
||||
});
|
||||
if (!resolved) {
|
||||
logVerbose("telegram: no vision provider available for sticker description");
|
||||
return null;
|
||||
}
|
||||
|
||||
const { provider, model } = resolved;
|
||||
if (!model) {
|
||||
logVerbose(`telegram: no vision model available for ${provider}`);
|
||||
return null;
|
||||
}
|
||||
logVerbose(`telegram: describing sticker with ${provider}/${model}`);
|
||||
|
||||
try {
|
||||
const buffer = await fs.readFile(imagePath);
|
||||
// Dynamic import to avoid circular dependency
|
||||
const { describeImageWithModel } = await import("../media-understanding/providers/image.js");
|
||||
const result = await describeImageWithModel({
|
||||
buffer,
|
||||
fileName: "sticker.webp",
|
||||
mime: "image/webp",
|
||||
prompt: STICKER_DESCRIPTION_PROMPT,
|
||||
cfg,
|
||||
agentDir: agentDir ?? "",
|
||||
provider,
|
||||
model,
|
||||
maxTokens: 150,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
return result.text;
|
||||
} catch (err) {
|
||||
logVerbose(`telegram: failed to describe sticker: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user