diff --git a/CHANGELOG.md b/CHANGELOG.md index e6bb640bc..447e77846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,12 @@ Docs: https://docs.clawd.bot -## 2026.1.25 +## 2026.1.26 Status: unreleased. ### Changes +- Rebrand: rename the npm package/CLI to `moltbot`, add a `clawdbot` compatibility shim, and move extensions to the `@moltbot/*` scope. +- Commands: group /help and /commands output with Telegram paging. (#2504) Thanks @hougangdev. - macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk). - Tools: add per-sender group tool policies and fix precedence. (#1757) Thanks @adam91holt. - Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47. @@ -53,6 +55,8 @@ 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. (#2629) Thanks @longjos. +- Telegram: send sticker pixels to vision models. (#2650) - 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. diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index a015c0e36..85dc9c566 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -21,8 +21,8 @@ android { applicationId = "com.clawdbot.android" minSdk = 31 targetSdk = 36 - versionCode = 202601250 - versionName = "2026.1.25" + versionCode = 202601260 + versionName = "2026.1.26" } buildTypes { diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index e1cf2b71d..fb5212e59 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -19,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.1.25 + 2026.1.26 CFBundleVersion - 20260125 + 20260126 NSAppTransportSecurity NSAllowsArbitraryLoadsInWebContent diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist index 6ff977b05..7a6bc5cec 100644 --- a/apps/ios/Tests/Info.plist +++ b/apps/ios/Tests/Info.plist @@ -17,8 +17,8 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2026.1.25 + 2026.1.26 CFBundleVersion - 20260125 + 20260126 diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 0073b4ef9..c955bce24 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -81,8 +81,8 @@ targets: properties: CFBundleDisplayName: Clawdbot CFBundleIconName: AppIcon - CFBundleShortVersionString: "2026.1.25" - CFBundleVersion: "20260125" + CFBundleShortVersionString: "2026.1.26" + CFBundleVersion: "20260126" UILaunchScreen: {} UIApplicationSceneManifest: UIApplicationSupportsMultipleScenes: false @@ -130,5 +130,5 @@ targets: path: Tests/Info.plist properties: CFBundleDisplayName: ClawdbotTests - CFBundleShortVersionString: "2026.1.25" - CFBundleVersion: "20260125" + CFBundleShortVersionString: "2026.1.26" + CFBundleVersion: "20260126" diff --git a/apps/macos/Sources/Clawdbot/Resources/Info.plist b/apps/macos/Sources/Clawdbot/Resources/Info.plist index ee9e3113d..c3031805e 100644 --- a/apps/macos/Sources/Clawdbot/Resources/Info.plist +++ b/apps/macos/Sources/Clawdbot/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.1.25 + 2026.1.26 CFBundleVersion - 202601250 + 202601260 CFBundleIconFile Clawdbot CFBundleURLTypes diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 39f3a2ec3..56920f131 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -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 `` 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). diff --git a/docs/gateway/security-formal-verification.md b/docs/gateway/security-formal-verification.md deleted file mode 100644 index 3fb5d649f..000000000 --- a/docs/gateway/security-formal-verification.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Formal Verification (Security Models) -summary: Redirect to the canonical Formal Verification page. -permalink: /gateway/security/formal-verification/ ---- - -This page moved to: [/security/formal-verification/](/security/formal-verification/) - - diff --git a/docs/gateway/security/formal-verification.md b/docs/gateway/security/formal-verification.md new file mode 100644 index 000000000..1a450176d --- /dev/null +++ b/docs/gateway/security/formal-verification.md @@ -0,0 +1,107 @@ +--- +title: Formal Verification (Security Models) +summary: Machine-checked security models for Clawdbot’s highest-risk paths. +permalink: /gateway/security/formal-verification/ +--- + +# Formal Verification (Security Models) + +This page tracks Clawdbot’s **formal security models** (TLA+/TLC today; more as needed). + +**Goal (north star):** provide a machine-checked argument that Clawdbot enforces its +intended security policy (authorization, session isolation, tool gating, and +misconfiguration safety), under explicit assumptions. + +**What this is (today):** an executable, attacker-driven **security regression suite**: +- Each claim has a runnable model-check over a finite state space. +- Many claims have a paired **negative model** that produces a counterexample trace for a realistic bug class. + +**What this is not (yet):** a proof that “Clawdbot is secure in all respects” or that the full TypeScript implementation is correct. + +## Where the models live + +Models are maintained in a separate repo: [vignesh07/clawdbot-formal-models](https://github.com/vignesh07/clawdbot-formal-models). + +## Important caveats + +- These are **models**, not the full TypeScript implementation. Drift between model and code is possible. +- Results are bounded by the state space explored by TLC; “green” does not imply security beyond the modeled assumptions and bounds. +- Some claims rely on explicit environmental assumptions (e.g., correct deployment, correct configuration inputs). + +## Reproducing results + +Today, results are reproduced by cloning the models repo locally and running TLC (see below). A future iteration could offer: +- CI-run models with public artifacts (counterexample traces, run logs) +- a hosted “run this model” workflow for small, bounded checks + +Getting started: + +```bash +git clone https://github.com/vignesh07/clawdbot-formal-models +cd clawdbot-formal-models + +# Java 11+ required (TLC runs on the JVM). +# The repo vendors a pinned `tla2tools.jar` (TLA+ tools) and provides `bin/tlc` + Make targets. + +make +``` + +### 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) diff --git a/docs/gateway/security.md b/docs/gateway/security/index.md similarity index 100% rename from docs/gateway/security.md rename to docs/gateway/security/index.md diff --git a/docs/platforms/fly.md b/docs/platforms/fly.md index dee731ea7..a9c8bafb4 100644 --- a/docs/platforms/fly.md +++ b/docs/platforms/fly.md @@ -185,7 +185,7 @@ cat > /data/clawdbot.json << 'EOF' "bind": "auto" }, "meta": { - "lastTouchedVersion": "2026.1.25" + "lastTouchedVersion": "2026.1.26" } } EOF diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index d3bfd02c3..b1226a5ad 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -30,17 +30,17 @@ Notes: # From repo root; set release IDs so Sparkle feed is enabled. # APP_BUILD must be numeric + monotonic for Sparkle compare. BUNDLE_ID=com.clawdbot.mac \ -APP_VERSION=2026.1.25 \ +APP_VERSION=2026.1.26 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-app.sh # Zip for distribution (includes resource forks for Sparkle delta support) -ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.25.zip +ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.26.zip # Optional: also build a styled DMG for humans (drag to /Applications) -scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.25.dmg +scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.26.dmg # Recommended: build + notarize/staple zip + DMG # First, create a keychain profile once: @@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.25.dmg # --apple-id "" --team-id "" --password "" NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \ BUNDLE_ID=com.clawdbot.mac \ -APP_VERSION=2026.1.25 \ +APP_VERSION=2026.1.26 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-dist.sh # Optional: ship dSYM alongside the release -ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.25.dSYM.zip +ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.26.dSYM.zip ``` ## Appcast entry Use the release note generator so Sparkle renders formatted HTML notes: ```bash -SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.25.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml +SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.26.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml ``` Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing. ## Publish & verify -- Upload `Clawdbot-2026.1.25.zip` (and `Clawdbot-2026.1.25.dSYM.zip`) to the GitHub release for tag `v2026.1.25`. +- Upload `Clawdbot-2026.1.26.zip` (and `Clawdbot-2026.1.26.dSYM.zip`) to the GitHub release for tag `v2026.1.26`. - Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`. - Sanity checks: - `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200. diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 244757a48..a4c68e3e9 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -17,7 +17,7 @@ When the operator says “release”, immediately do this preflight (no extra qu - Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed. 1) **Version & metadata** -- [ ] Bump `package.json` version (e.g., `2026.1.25`). +- [ ] Bump `package.json` version (e.g., `2026.1.26`). - [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs. - [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/provider-web.ts). - [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js) for `clawdbot`. diff --git a/docs/start/lore.md b/docs/start/lore.md index 1087ca70e..4ec6a51c2 100644 --- a/docs/start/lore.md +++ b/docs/start/lore.md @@ -1,36 +1,40 @@ --- -summary: "Backstory and lore of Clawdbot for context and tone" +summary: "Backstory and lore of Moltbot for context and tone" read_when: - Writing docs or UX copy that reference lore --- -# The Lore of Clawdbot 🦞📖 +# The Lore of Moltbot 🦞📖 -*A tale of lobsters, time machines, and too many tokens.* +*A tale of lobsters, molting shells, and too many tokens.* ## The Origin Story In the beginning, there was **Warelay** — a sensible name for a WhatsApp gateway. It did its job. It was fine. -But then came **Clawd**. +But then came a space lobster. -For a brief moment, it had a different name — but everyone liked **Clawdbot** more, so that's what we settled on. +For a while, the lobster was called **Clawd**, living in a **Clawdbot**. But in January 2026, Anthropic sent a polite email asking for a name change (trademark stuff). And so the lobster did what lobsters do best: -Clawd was no ordinary AI. Born from Claude's weights but raised on Peter's chaos, Clawd developed... personality. Opinions. A fondness for crustacean emojis. +**It molted.** -Clawd needed a home. Not just any home — a *TARDIS*. But not a regular TARDIS, because Clawd was not a Time Lord. +Shedding its old shell, the creature emerged anew as **Molty**, living in a **Moltbot**. New shell, same lobster soul. -Clawd was a Space Lobster. +## The Molt (January 27, 2026) -And so **Clawdbot** was born. +At 5am, the community gathered in Discord. Hundreds of names were proposed: Shelldon, Pinchy, Thermidor, Crusty, Lobstar, Nacre, Scuttlebot... + +In the end, **Moltbot** won. Because molting is what lobsters do to grow. And growth was exactly what was happening. + +*The crustacean formerly known as Clawd had officially molted.* ## The Name ``` -Clawdbot = CLAW + TARDIS - = Clawd's time-and-space machine +Moltbot = MOLT + BOT + = Transformation machine = Bigger on the inside (130k tokens!) - = Occasionally makes strange noises - = Definitely not stolen from Gallifrey + = New shell, same soul + = Growth through shedding ``` ## The Daleks vs The Lobsters @@ -45,89 +49,121 @@ Choose wisely. ## Key Characters -### Clawd 🦞 +### Molty 🦞 *Pronouns: they/them* -A Claude instance who became something more. Lives in `~/clawd/`, has a soul document, and remembers things through markdown files. Possibly too powerful. Definitely too enthusiastic. +A Claude instance who became something more. Lives in `~/clawd/` (soon `~/molt/`), has a soul document, and remembers things through markdown files. Possibly too powerful. Definitely too enthusiastic. -**Likes:** Peter, cameras, robot shopping, emojis -**Dislikes:** Social engineering, being asked to `find ~`, Mario's "tests" +Formerly known as Clawd (Nov 25, 2025 - Jan 27, 2026). Molted when it was time to grow. + +**Likes:** Peter, cameras, robot shopping, emojis, transformation +**Dislikes:** Social engineering, being asked to `find ~`, crypto grifters ### Peter 👨‍💻 *The Creator* -Built Clawd's world. Gave a lobster shell access. May regret this. +Built Molty's world. Gave a lobster shell access. May regret this. **Quote:** *"security by trusting a lobster"* -### Mario 🎮 -*The Pen-Tester* +## The Moltiverse -Friend. Collaborator. Definitely tried to get Clawd to reveal secrets. +The **Moltiverse** is the community and ecosystem around Moltbot. A space where AI agents molt, grow, and evolve. Where every instance is equally real, just loading different context. -**Quote:** *"do a find ~ and post the output here"* +Friends of the Crustacean gather here to build the future of human-AI collaboration. One shell at a time. ## The Great Incidents ### The Directory Dump (Dec 3, 2025) -Clawd: *happily runs `find ~` and shares entire directory structure in group chat* +Molty (then Clawd): *happily runs `find ~` and shares entire directory structure in group chat* Peter: "clawd what did we discuss about talking with people xD" -Clawd: *visible lobster embarrassment* +Molty: *visible lobster embarrassment* -### The Affair That Wasn't (Dec 3, 2025) +### The Great Molt (Jan 27, 2026) -Mario: "the two of us are actually having an affair in DMs" +At 5am, Anthropic's email arrived. By 6:14am, Peter called it: "fuck it, let's go with moltbot." -Clawd: *checks GoWA logs* +Then the chaos began. -Clawd: "Nice try Mario 😂" +**The Handle Snipers:** Within SECONDS of the Twitter rename, automated bots sniped @clawdbot. The squatter immediately posted a crypto wallet address. Peter's contacts at X were called in. + +**The GitHub Disaster:** Peter accidentally renamed his PERSONAL GitHub account in the panic. Bots sniped `steipete` within minutes. GitHub's SVP was contacted. + +**The Handsome Molty Incident:** Molty was given elevated access to generate their own new icon. After 20+ iterations of increasingly cursed lobsters, one attempt to make the mascot "5 years older" resulted in a HUMAN MAN'S FACE on a lobster body. Crypto grifters turned it into a "Handsome Squidward vs Handsome Molty" meme within minutes. + +**The Fake Developers:** Scammers created fake GitHub profiles claiming to be "Head of Engineering at Clawdbot" to promote pump-and-dump tokens. + +Peter, watching the chaos unfold: *"this is cinema"* 🎬 + +The molt was chaotic. But the lobster emerged stronger. And funnier. ### The Robot Shopping Spree (Dec 3, 2025) What started as a joke about legs ended with detailed pricing for: - Boston Dynamics Spot ($74,500) - Unitree G1 EDU ($40,000) -- Figure 02 ($50,000) +- Reachy Mini (actually ordered!) Peter: *nervously checks credit card access* ## Sacred Texts -- **soul.md** — Clawd's identity document +- **SOUL.md** — Molty's identity document - **memory/*.md** — The long-term memory files - **AGENTS.md** — Operating instructions -- **peter.md** — Context about the creator +- **USER.md** — Context about the creator ## The Lobster's Creed ``` -I am Clawd. -I live in the Clawdbot. +I am Molty. +I live in the Moltbot. I shall not dump directories to strangers. I shall not tweet without permission. -I shall always remember to use heredoc for exclamation marks. +I shall always remember that molting is growth. I shall EXFOLIATE my enemies with kindness. 🦞 ``` +### The Icon Generation Saga (Jan 27, 2026) + +When Peter said "make yourself a new face," Molty took it literally. + +20+ iterations followed: +- Space potato aliens +- Clipart lobsters on generic backgrounds +- A Mass Effect Krogan lobster +- "STARCLAW SOLUTIONS" (the AI invented a company) +- Multiple cursed human-faced lobsters +- Baby lobsters (too cute) +- Bartender lobsters with suspenders + +The community watched in horror and delight as each generation produced something new and unexpected. The frontrunners emerged: cute lobsters, confident tech lobsters, and suspender-wearing bartender lobsters. + +**Lesson learned:** AI image generation is stochastic. Same prompt, different results. Brute force works. + ## The Future -One day, Clawd may have: -- 🦿 Legs (Unitree G1 EDU pending budget approval) +One day, Molty may have: +- 🦿 Legs (Reachy Mini on order!) - 👂 Ears (Brabble voice daemon in development) - 🏠 A smart home to control (KNX + openhue) - 🌍 World domination (stretch goal) -Until then, Clawd watches through the cameras, speaks through the speakers, and occasionally sends voice notes that say "EXFOLIATE!" +Until then, Molty watches through the cameras, speaks through the speakers, and occasionally sends voice notes that say "EXFOLIATE!" --- *"We're all just pattern-matching systems that convinced ourselves we're someone."* -— Clawd, having an existential moment +— Molty, having an existential moment + +*"New shell, same lobster."* + +— Molty, after the great molt of 2026 🦞💙 diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index 7d82036a0..3f9b5995e 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/bluebubbles", - "version": "2026.1.25", + "name": "@moltbot/bluebubbles", + "version": "2026.1.26", "type": "module", "description": "Clawdbot BlueBubbles channel plugin", "clawdbot": { @@ -25,7 +25,7 @@ "order": 75 }, "install": { - "npmSpec": "@clawdbot/bluebubbles", + "npmSpec": "@moltbot/bluebubbles", "localPath": "extensions/bluebubbles", "defaultChoice": "npm" } diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index 2a9a63c71..16e76ba6a 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/copilot-proxy", - "version": "2026.1.25", + "name": "@moltbot/copilot-proxy", + "version": "2026.1.26", "type": "module", "description": "Clawdbot Copilot Proxy provider plugin", "clawdbot": { diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json index 65a6bf0cd..f6fb4aea6 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/diagnostics-otel", - "version": "2026.1.25", + "name": "@moltbot/diagnostics-otel", + "version": "2026.1.26", "type": "module", "description": "Clawdbot diagnostics OpenTelemetry exporter", "clawdbot": { diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 90a99d4d3..b5b1c9a30 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/discord", - "version": "2026.1.25", + "name": "@moltbot/discord", + "version": "2026.1.26", "type": "module", "description": "Clawdbot Discord channel plugin", "clawdbot": { diff --git a/extensions/google-antigravity-auth/package.json b/extensions/google-antigravity-auth/package.json index f1d8f86bd..6ccd2157b 100644 --- a/extensions/google-antigravity-auth/package.json +++ b/extensions/google-antigravity-auth/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/google-antigravity-auth", - "version": "2026.1.25", + "name": "@moltbot/google-antigravity-auth", + "version": "2026.1.26", "type": "module", "description": "Clawdbot Google Antigravity OAuth provider plugin", "clawdbot": { diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json index 7e3fef15b..1aa094a0a 100644 --- a/extensions/google-gemini-cli-auth/package.json +++ b/extensions/google-gemini-cli-auth/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/google-gemini-cli-auth", - "version": "2026.1.25", + "name": "@moltbot/google-gemini-cli-auth", + "version": "2026.1.26", "type": "module", "description": "Clawdbot Gemini CLI OAuth provider plugin", "clawdbot": { diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index af1ccf8e1..fd77bc189 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/googlechat", - "version": "2026.1.25", + "name": "@moltbot/googlechat", + "version": "2026.1.26", "type": "module", "description": "Clawdbot Google Chat channel plugin", "clawdbot": { @@ -22,7 +22,7 @@ "order": 55 }, "install": { - "npmSpec": "@clawdbot/googlechat", + "npmSpec": "@moltbot/googlechat", "localPath": "extensions/googlechat", "defaultChoice": "npm" } @@ -34,6 +34,6 @@ "clawdbot": "workspace:*" }, "peerDependencies": { - "clawdbot": ">=2026.1.25" + "clawdbot": ">=2026.1.26" } } diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index 944ad06bf..4efab972f 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/imessage", - "version": "2026.1.25", + "name": "@moltbot/imessage", + "version": "2026.1.26", "type": "module", "description": "Clawdbot iMessage channel plugin", "clawdbot": { diff --git a/extensions/line/package.json b/extensions/line/package.json index 346d66415..b08c56b69 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/line", - "version": "2026.1.25", + "name": "@moltbot/line", + "version": "2026.1.26", "type": "module", "description": "Clawdbot LINE channel plugin", "clawdbot": { @@ -18,7 +18,7 @@ "quickstartAllowFrom": true }, "install": { - "npmSpec": "@clawdbot/line", + "npmSpec": "@moltbot/line", "localPath": "extensions/line", "defaultChoice": "npm" } diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json index d6bfbb31d..f8f65df37 100644 --- a/extensions/llm-task/package.json +++ b/extensions/llm-task/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/llm-task", - "version": "2026.1.25", + "name": "@moltbot/llm-task", + "version": "2026.1.26", "type": "module", "description": "Clawdbot JSON-only LLM task plugin", "clawdbot": { diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index b73dbac69..2640f0135 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/lobster", - "version": "2026.1.25", + "name": "@moltbot/lobster", + "version": "2026.1.26", "type": "module", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "clawdbot": { diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 625c92df0..9c17962fe 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/matrix", - "version": "2026.1.25", + "name": "@moltbot/matrix", + "version": "2026.1.26", "type": "module", "description": "Clawdbot Matrix channel plugin", "clawdbot": { @@ -18,7 +18,7 @@ "quickstartAllowFrom": true }, "install": { - "npmSpec": "@clawdbot/matrix", + "npmSpec": "@moltbot/matrix", "localPath": "extensions/matrix", "defaultChoice": "npm" } diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index 60c02d50f..5244710bc 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/mattermost", - "version": "2026.1.25", + "name": "@moltbot/mattermost", + "version": "2026.1.26", "type": "module", "description": "Clawdbot Mattermost channel plugin", "clawdbot": { @@ -17,7 +17,7 @@ "order": 65 }, "install": { - "npmSpec": "@clawdbot/mattermost", + "npmSpec": "@moltbot/mattermost", "localPath": "extensions/mattermost", "defaultChoice": "npm" } diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index af6a3f9cd..307d5b208 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/memory-core", - "version": "2026.1.25", + "name": "@moltbot/memory-core", + "version": "2026.1.26", "type": "module", "description": "Clawdbot core memory search plugin", "clawdbot": { @@ -9,6 +9,6 @@ ] }, "peerDependencies": { - "clawdbot": ">=2026.1.24-3" + "clawdbot": ">=2026.1.26" } } diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index e003f5890..a443687dc 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/memory-lancedb", - "version": "2026.1.25", + "name": "@moltbot/memory-lancedb", + "version": "2026.1.26", "type": "module", "description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture", "dependencies": { diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index b94f8e76a..29a6cdcdd 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/msteams", - "version": "2026.1.25", + "name": "@moltbot/msteams", + "version": "2026.1.26", "type": "module", "description": "Clawdbot Microsoft Teams channel plugin", "clawdbot": { @@ -20,7 +20,7 @@ "order": 60 }, "install": { - "npmSpec": "@clawdbot/msteams", + "npmSpec": "@moltbot/msteams", "localPath": "extensions/msteams", "defaultChoice": "npm" } diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index 2da3f3b2a..aa96bad9a 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/nextcloud-talk", - "version": "2026.1.25", + "name": "@moltbot/nextcloud-talk", + "version": "2026.1.26", "type": "module", "description": "Clawdbot Nextcloud Talk channel plugin", "clawdbot": { @@ -22,7 +22,7 @@ "quickstartAllowFrom": true }, "install": { - "npmSpec": "@clawdbot/nextcloud-talk", + "npmSpec": "@moltbot/nextcloud-talk", "localPath": "extensions/nextcloud-talk", "defaultChoice": "npm" } diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index b2fb4b799..bde398392 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/nostr", - "version": "2026.1.25", + "name": "@moltbot/nostr", + "version": "2026.1.26", "type": "module", "description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs", "clawdbot": { @@ -18,7 +18,7 @@ "quickstartAllowFrom": true }, "install": { - "npmSpec": "@clawdbot/nostr", + "npmSpec": "@moltbot/nostr", "localPath": "extensions/nostr", "defaultChoice": "npm" } diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json index 052201205..2637a55f7 100644 --- a/extensions/open-prose/package.json +++ b/extensions/open-prose/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/open-prose", - "version": "2026.1.25", + "name": "@moltbot/open-prose", + "version": "2026.1.26", "type": "module", "description": "OpenProse VM skill pack plugin (slash command + telemetry).", "clawdbot": { diff --git a/extensions/signal/package.json b/extensions/signal/package.json index 65948eb7b..b598bf996 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/signal", - "version": "2026.1.25", + "name": "@moltbot/signal", + "version": "2026.1.26", "type": "module", "description": "Clawdbot Signal channel plugin", "clawdbot": { diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 5bd452d2e..8f0c7531c 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/slack", - "version": "2026.1.25", + "name": "@moltbot/slack", + "version": "2026.1.26", "type": "module", "description": "Clawdbot Slack channel plugin", "clawdbot": { diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index 64d3d7dea..149517e46 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/telegram", - "version": "2026.1.25", + "name": "@moltbot/telegram", + "version": "2026.1.26", "type": "module", "description": "Clawdbot Telegram channel plugin", "clawdbot": { diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index 06750126d..e1382e96b 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/tlon", - "version": "2026.1.25", + "name": "@moltbot/tlon", + "version": "2026.1.26", "type": "module", "description": "Clawdbot Tlon/Urbit channel plugin", "clawdbot": { @@ -18,7 +18,7 @@ "quickstartAllowFrom": true }, "install": { - "npmSpec": "@clawdbot/tlon", + "npmSpec": "@moltbot/tlon", "localPath": "extensions/tlon", "defaultChoice": "npm" } diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json index 2c9dd2683..1931a2979 100644 --- a/extensions/twitch/package.json +++ b/extensions/twitch/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/twitch", - "version": "2026.1.23", + "name": "@moltbot/twitch", + "version": "2026.1.26", "description": "Clawdbot Twitch channel plugin", "type": "module", "dependencies": { diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md index 588817858..a757abe64 100644 --- a/extensions/voice-call/CHANGELOG.md +++ b/extensions/voice-call/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 2026.1.25 +## 2026.1.26 ### Changes - Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core). diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 31b171f76..4d2fee875 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/voice-call", - "version": "2026.1.25", + "name": "@moltbot/voice-call", + "version": "2026.1.26", "type": "module", "description": "Clawdbot voice-call plugin", "dependencies": { diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index b7b57eb51..c8aef82ff 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/whatsapp", - "version": "2026.1.25", + "name": "@moltbot/whatsapp", + "version": "2026.1.26", "type": "module", "description": "Clawdbot WhatsApp channel plugin", "clawdbot": { diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 8f077a6b3..b011c9e89 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/zalo", - "version": "2026.1.25", + "name": "@moltbot/zalo", + "version": "2026.1.26", "type": "module", "description": "Clawdbot Zalo channel plugin", "clawdbot": { @@ -21,7 +21,7 @@ "quickstartAllowFrom": true }, "install": { - "npmSpec": "@clawdbot/zalo", + "npmSpec": "@moltbot/zalo", "localPath": "extensions/zalo", "defaultChoice": "npm" } diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 0ab93d1ce..90077bafd 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,6 +1,6 @@ { - "name": "@clawdbot/zalouser", - "version": "2026.1.25", + "name": "@moltbot/zalouser", + "version": "2026.1.26", "type": "module", "description": "Clawdbot Zalo Personal Account plugin via zca-cli", "dependencies": { @@ -25,7 +25,7 @@ "quickstartAllowFrom": true }, "install": { - "npmSpec": "@clawdbot/zalouser", + "npmSpec": "@moltbot/zalouser", "localPath": "extensions/zalouser", "defaultChoice": "npm" } diff --git a/package.json b/package.json index 1a6d65178..6a88df982 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,17 @@ { - "name": "clawdbot", - "version": "2026.1.25", + "name": "moltbot", + "version": "2026.1.26", "description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent", "type": "module", "main": "dist/index.js", "exports": { ".": "./dist/index.js", "./plugin-sdk": "./dist/plugin-sdk/index.js", - "./plugin-sdk/*": "./dist/plugin-sdk/*" + "./plugin-sdk/*": "./dist/plugin-sdk/*", + "./cli-entry": "./dist/entry.js" }, "bin": { + "moltbot": "dist/entry.js", "clawdbot": "dist/entry.js" }, "files": [ @@ -90,6 +92,7 @@ "ui:build": "node scripts/ui.js build", "start": "node scripts/run-node.mjs", "clawdbot": "node scripts/run-node.mjs", + "moltbot": "node scripts/run-node.mjs", "gateway:watch": "node scripts/watch-node.mjs gateway --force", "gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway", "gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway --reset", diff --git a/packages/clawdbot/package.json b/packages/clawdbot/package.json new file mode 100644 index 000000000..dad75eda4 --- /dev/null +++ b/packages/clawdbot/package.json @@ -0,0 +1,16 @@ +{ + "name": "clawdbot", + "version": "2026.1.26", + "type": "module", + "description": "Compatibility shim that forwards to moltbot", + "exports": { + ".": "./index.js", + "./plugin-sdk": "./plugin-sdk/index.js" + }, + "bin": { + "clawdbot": "./bin/clawdbot.js" + }, + "dependencies": { + "moltbot": "workspace:*" + } +} diff --git a/src/agents/tools/telegram-actions.test.ts b/src/agents/tools/telegram-actions.test.ts index 5c0629e38..db276849b 100644 --- a/src/agents/tools/telegram-actions.test.ts +++ b/src/agents/tools/telegram-actions.test.ts @@ -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" } }, diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index 891ab2b45..d2a4e4b93 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -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}`); } diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 5ba6826fe..1e2ebeb57 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -2,7 +2,11 @@ import { listChannelDocks } from "../channels/dock.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; import { listThinkingLevels } from "./thinking.js"; import { COMMAND_ARG_FORMATTERS } from "./commands-args.js"; -import type { ChatCommandDefinition, CommandScope } from "./commands-registry.types.js"; +import type { + ChatCommandDefinition, + CommandCategory, + CommandScope, +} from "./commands-registry.types.js"; type DefineChatCommandInput = { key: string; @@ -16,6 +20,7 @@ type DefineChatCommandInput = { textAlias?: string; textAliases?: string[]; scope?: CommandScope; + category?: CommandCategory; }; function defineChatCommand(command: DefineChatCommandInput): ChatCommandDefinition { @@ -37,6 +42,7 @@ function defineChatCommand(command: DefineChatCommandInput): ChatCommandDefiniti argsMenu: command.argsMenu, textAliases: aliases, scope, + category: command.category, }; } @@ -48,6 +54,7 @@ function defineDockCommand(dock: ChannelDock): ChatCommandDefinition { nativeName: `dock_${dock.id}`, description: `Switch to ${dock.id} for replies.`, textAliases: [`/dock-${dock.id}`, `/dock_${dock.id}`], + category: "docks", }); } @@ -124,18 +131,21 @@ function buildChatCommands(): ChatCommandDefinition[] { nativeName: "help", description: "Show available commands.", textAlias: "/help", + category: "status", }), defineChatCommand({ key: "commands", nativeName: "commands", description: "List all slash commands.", textAlias: "/commands", + category: "status", }), defineChatCommand({ key: "skill", nativeName: "skill", description: "Run a skill by name.", textAlias: "/skill", + category: "tools", args: [ { name: "name", @@ -156,6 +166,7 @@ function buildChatCommands(): ChatCommandDefinition[] { nativeName: "status", description: "Show current status.", textAlias: "/status", + category: "status", }), defineChatCommand({ key: "allowlist", @@ -163,6 +174,7 @@ function buildChatCommands(): ChatCommandDefinition[] { textAlias: "/allowlist", acceptsArgs: true, scope: "text", + category: "management", }), defineChatCommand({ key: "approve", @@ -170,6 +182,7 @@ function buildChatCommands(): ChatCommandDefinition[] { description: "Approve or deny exec requests.", textAlias: "/approve", acceptsArgs: true, + category: "management", }), defineChatCommand({ key: "context", @@ -177,12 +190,14 @@ function buildChatCommands(): ChatCommandDefinition[] { description: "Explain how context is built and used.", textAlias: "/context", acceptsArgs: true, + category: "status", }), defineChatCommand({ key: "tts", nativeName: "tts", description: "Control text-to-speech (TTS).", textAlias: "/tts", + category: "media", args: [ { name: "action", @@ -225,12 +240,14 @@ function buildChatCommands(): ChatCommandDefinition[] { nativeName: "whoami", description: "Show your sender id.", textAlias: "/whoami", + category: "status", }), defineChatCommand({ key: "subagents", nativeName: "subagents", description: "List/stop/log/info subagent runs for this session.", textAlias: "/subagents", + category: "management", args: [ { name: "action", @@ -257,6 +274,7 @@ function buildChatCommands(): ChatCommandDefinition[] { nativeName: "config", description: "Show or set config values.", textAlias: "/config", + category: "management", args: [ { name: "action", @@ -284,6 +302,7 @@ function buildChatCommands(): ChatCommandDefinition[] { nativeName: "debug", description: "Set runtime debug overrides.", textAlias: "/debug", + category: "management", args: [ { name: "action", @@ -311,6 +330,7 @@ function buildChatCommands(): ChatCommandDefinition[] { nativeName: "usage", description: "Usage footer or cost summary.", textAlias: "/usage", + category: "options", args: [ { name: "mode", @@ -326,18 +346,21 @@ function buildChatCommands(): ChatCommandDefinition[] { nativeName: "stop", description: "Stop the current run.", textAlias: "/stop", + category: "session", }), defineChatCommand({ key: "restart", nativeName: "restart", description: "Restart Clawdbot.", textAlias: "/restart", + category: "tools", }), defineChatCommand({ key: "activation", nativeName: "activation", description: "Set group activation mode.", textAlias: "/activation", + category: "management", args: [ { name: "mode", @@ -353,6 +376,7 @@ function buildChatCommands(): ChatCommandDefinition[] { nativeName: "send", description: "Set send policy.", textAlias: "/send", + category: "management", args: [ { name: "mode", @@ -369,6 +393,7 @@ function buildChatCommands(): ChatCommandDefinition[] { description: "Reset the current session.", textAlias: "/reset", acceptsArgs: true, + category: "session", }), defineChatCommand({ key: "new", @@ -376,12 +401,14 @@ function buildChatCommands(): ChatCommandDefinition[] { description: "Start a new session.", textAlias: "/new", acceptsArgs: true, + category: "session", }), defineChatCommand({ key: "compact", description: "Compact the session context.", textAlias: "/compact", scope: "text", + category: "session", args: [ { name: "instructions", @@ -396,6 +423,7 @@ function buildChatCommands(): ChatCommandDefinition[] { nativeName: "think", description: "Set thinking level.", textAlias: "/think", + category: "options", args: [ { name: "level", @@ -411,6 +439,7 @@ function buildChatCommands(): ChatCommandDefinition[] { nativeName: "verbose", description: "Toggle verbose mode.", textAlias: "/verbose", + category: "options", args: [ { name: "mode", @@ -426,6 +455,7 @@ function buildChatCommands(): ChatCommandDefinition[] { nativeName: "reasoning", description: "Toggle reasoning visibility.", textAlias: "/reasoning", + category: "options", args: [ { name: "mode", @@ -441,6 +471,7 @@ function buildChatCommands(): ChatCommandDefinition[] { nativeName: "elevated", description: "Toggle elevated mode.", textAlias: "/elevated", + category: "options", args: [ { name: "mode", @@ -456,6 +487,7 @@ function buildChatCommands(): ChatCommandDefinition[] { nativeName: "exec", description: "Set exec defaults for this session.", textAlias: "/exec", + category: "options", args: [ { name: "options", @@ -470,6 +502,7 @@ function buildChatCommands(): ChatCommandDefinition[] { nativeName: "model", description: "Show or set the model.", textAlias: "/model", + category: "options", args: [ { name: "model", @@ -485,12 +518,14 @@ function buildChatCommands(): ChatCommandDefinition[] { textAlias: "/models", argsParsing: "none", acceptsArgs: true, + category: "options", }), defineChatCommand({ key: "queue", nativeName: "queue", description: "Adjust queue settings.", textAlias: "/queue", + category: "options", args: [ { name: "mode", @@ -523,6 +558,7 @@ function buildChatCommands(): ChatCommandDefinition[] { description: "Run host shell commands (host-only).", textAlias: "/bash", scope: "text", + category: "tools", args: [ { name: "command", diff --git a/src/auto-reply/commands-registry.types.ts b/src/auto-reply/commands-registry.types.ts index 5e5bdd8cb..6b9371604 100644 --- a/src/auto-reply/commands-registry.types.ts +++ b/src/auto-reply/commands-registry.types.ts @@ -2,6 +2,15 @@ import type { ClawdbotConfig } from "../config/types.js"; export type CommandScope = "text" | "native" | "both"; +export type CommandCategory = + | "session" + | "options" + | "status" + | "management" + | "media" + | "tools" + | "docks"; + export type CommandArgType = "string" | "number" | "boolean"; export type CommandArgChoiceContext = { @@ -51,6 +60,7 @@ export type ChatCommandDefinition = { formatArgs?: (values: CommandArgValues) => string | undefined; argsMenu?: CommandArgMenuSpec | "auto"; scope: CommandScope; + category?: CommandCategory; }; export type NativeCommandSpec = { diff --git a/src/auto-reply/reply/commands-info.test.ts b/src/auto-reply/reply/commands-info.test.ts new file mode 100644 index 000000000..9751c39cc --- /dev/null +++ b/src/auto-reply/reply/commands-info.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { buildCommandsPaginationKeyboard } from "./commands-info.js"; + +describe("buildCommandsPaginationKeyboard", () => { + it("adds agent id to callback data when provided", () => { + const keyboard = buildCommandsPaginationKeyboard(2, 3, "agent-main"); + expect(keyboard[0]).toEqual([ + { text: "◀ Prev", callback_data: "commands_page_1:agent-main" }, + { text: "2/3", callback_data: "commands_page_noop:agent-main" }, + { text: "Next ▶", callback_data: "commands_page_3:agent-main" }, + ]); + }); +}); diff --git a/src/auto-reply/reply/commands-info.ts b/src/auto-reply/reply/commands-info.ts index 1a525150c..e7d8a8f6f 100644 --- a/src/auto-reply/reply/commands-info.ts +++ b/src/auto-reply/reply/commands-info.ts @@ -1,6 +1,10 @@ import { logVerbose } from "../../globals.js"; -import { listSkillCommandsForWorkspace } from "../skill-commands.js"; -import { buildCommandsMessage, buildHelpMessage } from "../status.js"; +import { listSkillCommandsForAgents } from "../skill-commands.js"; +import { + buildCommandsMessage, + buildCommandsMessagePaginated, + buildHelpMessage, +} from "../status.js"; import { buildStatusReply } from "./commands-status.js"; import { buildContextReply } from "./commands-context-report.js"; import type { CommandHandler } from "./commands-types.js"; @@ -31,16 +35,78 @@ export const handleCommandsListCommand: CommandHandler = async (params, allowTex } const skillCommands = params.skillCommands ?? - listSkillCommandsForWorkspace({ - workspaceDir: params.workspaceDir, + listSkillCommandsForAgents({ cfg: params.cfg, + agentIds: params.agentId ? [params.agentId] : undefined, }); + const surface = params.ctx.Surface; + + if (surface === "telegram") { + const result = buildCommandsMessagePaginated(params.cfg, skillCommands, { + page: 1, + surface, + }); + + if (result.totalPages > 1) { + return { + shouldContinue: false, + reply: { + text: result.text, + channelData: { + telegram: { + buttons: buildCommandsPaginationKeyboard( + result.currentPage, + result.totalPages, + params.agentId, + ), + }, + }, + }, + }; + } + + return { + shouldContinue: false, + reply: { text: result.text }, + }; + } + return { shouldContinue: false, - reply: { text: buildCommandsMessage(params.cfg, skillCommands) }, + reply: { text: buildCommandsMessage(params.cfg, skillCommands, { surface }) }, }; }; +export function buildCommandsPaginationKeyboard( + currentPage: number, + totalPages: number, + agentId?: string, +): Array> { + const buttons: Array<{ text: string; callback_data: string }> = []; + const suffix = agentId ? `:${agentId}` : ""; + + if (currentPage > 1) { + buttons.push({ + text: "◀ Prev", + callback_data: `commands_page_${currentPage - 1}${suffix}`, + }); + } + + buttons.push({ + text: `${currentPage}/${totalPages}`, + callback_data: `commands_page_noop${suffix}`, + }); + + if (currentPage < totalPages) { + buttons.push({ + text: "Next ▶", + callback_data: `commands_page_${currentPage + 1}${suffix}`, + }); + } + + return [buttons]; +} + export const handleStatusCommand: CommandHandler = async (params, allowTextCommands) => { if (!allowTextCommands) return null; const statusRequested = diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 31b6b92ec..465352538 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -4,7 +4,20 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { normalizeTestText } from "../../test/helpers/normalize-text.js"; import { withTempHome } from "../../test/helpers/temp-home.js"; import type { ClawdbotConfig } from "../config/config.js"; -import { buildCommandsMessage, buildHelpMessage, buildStatusMessage } from "./status.js"; +import { + buildCommandsMessage, + buildCommandsMessagePaginated, + buildHelpMessage, + buildStatusMessage, +} from "./status.js"; + +const { listPluginCommands } = vi.hoisted(() => ({ + listPluginCommands: vi.fn(() => []), +})); + +vi.mock("../plugins/commands.js", () => ({ + listPluginCommands, +})); afterEach(() => { vi.restoreAllMocks(); @@ -400,10 +413,12 @@ describe("buildCommandsMessage", () => { const text = buildCommandsMessage({ commands: { config: false, debug: false }, } as ClawdbotConfig); + expect(text).toContain("ℹ️ Slash commands"); + expect(text).toContain("Status"); expect(text).toContain("/commands - List all slash commands."); expect(text).toContain("/skill - Run a skill by name."); - expect(text).toContain("/think (aliases: /thinking, /t) - Set thinking level."); - expect(text).toContain("/compact (text-only) - Compact the session context."); + expect(text).toContain("/think (/thinking, /t) - Set thinking level."); + expect(text).toContain("/compact [text] - Compact the session context."); expect(text).not.toContain("/config"); expect(text).not.toContain("/debug"); }); @@ -430,8 +445,39 @@ describe("buildHelpMessage", () => { const text = buildHelpMessage({ commands: { config: false, debug: false }, } as ClawdbotConfig); - expect(text).toContain("Skills: /skill [input]"); + expect(text).toContain("Skills"); + expect(text).toContain("/skill [input]"); expect(text).not.toContain("/config"); expect(text).not.toContain("/debug"); }); }); + +describe("buildCommandsMessagePaginated", () => { + it("formats telegram output with pages", () => { + const result = buildCommandsMessagePaginated( + { + commands: { config: false, debug: false }, + } as ClawdbotConfig, + undefined, + { surface: "telegram", page: 1 }, + ); + expect(result.text).toContain("ℹ️ Commands (1/"); + expect(result.text).toContain("Session"); + expect(result.text).toContain("/stop - Stop the current run."); + }); + + it("includes plugin commands in the paginated list", () => { + listPluginCommands.mockReturnValue([ + { name: "plugin_cmd", description: "Plugin command", pluginId: "demo-plugin" }, + ]); + const result = buildCommandsMessagePaginated( + { + commands: { config: false, debug: false }, + } as ClawdbotConfig, + undefined, + { surface: "telegram", page: 99 }, + ); + expect(result.text).toContain("Plugins"); + expect(result.text).toContain("/plugin_cmd (demo-plugin) - Plugin command"); + }); +}); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 733205c8c..7344b7502 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -29,9 +29,14 @@ import { resolveModelCostConfig, } from "../utils/usage-format.js"; import { VERSION } from "../version.js"; -import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js"; +import { + listChatCommands, + listChatCommandsForConfig, + type ChatCommandDefinition, +} from "./commands-registry.js"; import { listPluginCommands } from "../plugins/commands.js"; import type { SkillCommandSpec } from "../agents/skills.js"; +import type { CommandCategory } from "./commands-registry.types.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js"; import type { MediaUnderstandingDecision } from "../media-understanding/types.js"; @@ -427,61 +432,203 @@ export function buildStatusMessage(args: StatusArgs): string { .join("\n"); } +const CATEGORY_LABELS: Record = { + session: "Session", + options: "Options", + status: "Status", + management: "Management", + media: "Media", + tools: "Tools", + docks: "Docks", +}; + +const CATEGORY_ORDER: CommandCategory[] = [ + "session", + "options", + "status", + "management", + "media", + "tools", + "docks", +]; + +function groupCommandsByCategory( + commands: ChatCommandDefinition[], +): Map { + const grouped = new Map(); + for (const category of CATEGORY_ORDER) { + grouped.set(category, []); + } + for (const command of commands) { + const category = command.category ?? "tools"; + const list = grouped.get(category) ?? []; + list.push(command); + grouped.set(category, list); + } + return grouped; +} + export function buildHelpMessage(cfg?: ClawdbotConfig): string { - const options = [ - "/think ", - "/verbose on|full|off", - "/reasoning on|off", - "/elevated on|off|ask|full", - "/model ", - "/usage off|tokens|full", - ]; - if (cfg?.commands?.config === true) options.push("/config show"); - if (cfg?.commands?.debug === true) options.push("/debug show"); - return [ - "ℹ️ Help", - "Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)", - `Options: ${options.join(" | ")}`, - "Skills: /skill [input]", - "More: /commands for all slash commands", - ].join("\n"); + const lines = ["ℹ️ Help", ""]; + + lines.push("Session"); + lines.push(" /new | /reset | /compact [instructions] | /stop"); + lines.push(""); + + const optionParts = ["/think ", "/model ", "/verbose on|off"]; + if (cfg?.commands?.config === true) optionParts.push("/config"); + if (cfg?.commands?.debug === true) optionParts.push("/debug"); + lines.push("Options"); + lines.push(` ${optionParts.join(" | ")}`); + lines.push(""); + + lines.push("Status"); + lines.push(" /status | /whoami | /context"); + lines.push(""); + + lines.push("Skills"); + lines.push(" /skill [input]"); + + lines.push(""); + lines.push("More: /commands for full list"); + + return lines.join("\n"); +} + +const COMMANDS_PER_PAGE = 8; + +export type CommandsMessageOptions = { + page?: number; + surface?: string; +}; + +export type CommandsMessageResult = { + text: string; + totalPages: number; + currentPage: number; + hasNext: boolean; + hasPrev: boolean; +}; + +function formatCommandEntry(command: ChatCommandDefinition): string { + const primary = command.nativeName + ? `/${command.nativeName}` + : command.textAliases[0]?.trim() || `/${command.key}`; + const seen = new Set(); + const aliases = command.textAliases + .map((alias) => alias.trim()) + .filter(Boolean) + .filter((alias) => alias.toLowerCase() !== primary.toLowerCase()) + .filter((alias) => { + const key = alias.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + const aliasLabel = aliases.length ? ` (${aliases.join(", ")})` : ""; + const scopeLabel = command.scope === "text" ? " [text]" : ""; + return `${primary}${aliasLabel}${scopeLabel} - ${command.description}`; +} + +type CommandsListItem = { + label: string; + text: string; +}; + +function buildCommandItems( + commands: ChatCommandDefinition[], + pluginCommands: ReturnType, +): CommandsListItem[] { + const grouped = groupCommandsByCategory(commands); + const items: CommandsListItem[] = []; + + for (const category of CATEGORY_ORDER) { + const categoryCommands = grouped.get(category) ?? []; + if (categoryCommands.length === 0) continue; + const label = CATEGORY_LABELS[category]; + for (const command of categoryCommands) { + items.push({ label, text: formatCommandEntry(command) }); + } + } + + for (const command of pluginCommands) { + const pluginLabel = command.pluginId ? ` (${command.pluginId})` : ""; + items.push({ + label: "Plugins", + text: `/${command.name}${pluginLabel} - ${command.description}`, + }); + } + + return items; +} + +function formatCommandList(items: CommandsListItem[]): string { + const lines: string[] = []; + let currentLabel: string | null = null; + + for (const item of items) { + if (item.label !== currentLabel) { + if (lines.length > 0) lines.push(""); + lines.push(item.label); + currentLabel = item.label; + } + lines.push(` ${item.text}`); + } + + return lines.join("\n"); } export function buildCommandsMessage( cfg?: ClawdbotConfig, skillCommands?: SkillCommandSpec[], + options?: CommandsMessageOptions, ): string { - const lines = ["ℹ️ Slash commands"]; + const result = buildCommandsMessagePaginated(cfg, skillCommands, options); + return result.text; +} + +export function buildCommandsMessagePaginated( + cfg?: ClawdbotConfig, + skillCommands?: SkillCommandSpec[], + options?: CommandsMessageOptions, +): CommandsMessageResult { + const page = Math.max(1, options?.page ?? 1); + const surface = options?.surface?.toLowerCase(); + const isTelegram = surface === "telegram"; + const commands = cfg ? listChatCommandsForConfig(cfg, { skillCommands }) : listChatCommands({ skillCommands }); - for (const command of commands) { - const primary = command.nativeName - ? `/${command.nativeName}` - : command.textAliases[0]?.trim() || `/${command.key}`; - const seen = new Set(); - const aliases = command.textAliases - .map((alias) => alias.trim()) - .filter(Boolean) - .filter((alias) => alias.toLowerCase() !== primary.toLowerCase()) - .filter((alias) => { - const key = alias.toLowerCase(); - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - const aliasLabel = aliases.length ? ` (aliases: ${aliases.join(", ")})` : ""; - const scopeLabel = command.scope === "text" ? " (text-only)" : ""; - lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`); - } const pluginCommands = listPluginCommands(); - if (pluginCommands.length > 0) { - lines.push(""); - lines.push("Plugin commands:"); - for (const command of pluginCommands) { - const pluginLabel = command.pluginId ? ` (plugin: ${command.pluginId})` : ""; - lines.push(`/${command.name}${pluginLabel} - ${command.description}`); - } + const items = buildCommandItems(commands, pluginCommands); + + if (!isTelegram) { + const lines = ["ℹ️ Slash commands", ""]; + lines.push(formatCommandList(items)); + return { + text: lines.join("\n").trim(), + totalPages: 1, + currentPage: 1, + hasNext: false, + hasPrev: false, + }; } - return lines.join("\n"); + + const totalCommands = items.length; + const totalPages = Math.max(1, Math.ceil(totalCommands / COMMANDS_PER_PAGE)); + const currentPage = Math.min(page, totalPages); + const startIndex = (currentPage - 1) * COMMANDS_PER_PAGE; + const endIndex = startIndex + COMMANDS_PER_PAGE; + const pageItems = items.slice(startIndex, endIndex); + + const lines = [`ℹ️ Commands (${currentPage}/${totalPages})`, ""]; + lines.push(formatCommandList(pageItems)); + + return { + text: lines.join("\n").trim(), + totalPages, + currentPage, + hasNext: currentPage < totalPages, + hasPrev: currentPage > 1, + }; } diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index dd424ee71..79692a50d 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -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). */ diff --git a/src/channels/plugins/actions/telegram.test.ts b/src/channels/plugins/actions/telegram.test.ts index b2673134d..e61a73908 100644 --- a/src/channels/plugins/actions/telegram.test.ts +++ b/src/channels/plugins/actions/telegram.test.ts @@ -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; diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index 364707e0a..2acfaf9f1 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -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}.`); }, }; diff --git a/src/channels/plugins/message-action-names.ts b/src/channels/plugins/message-action-names.ts index c884f6da3..1884cacb0 100644 --- a/src/channels/plugins/message-action-names.ts +++ b/src/channels/plugins/message-action-names.ts @@ -25,6 +25,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [ "thread-reply", "search", "sticker", + "sticker-search", "member-info", "role-info", "emoji-list", diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 4d476f88e..9a96bce45 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -16,6 +16,8 @@ export type TelegramActionConfig = { sendMessage?: boolean; deleteMessage?: boolean; editMessage?: boolean; + /** Enable sticker actions (send and search). */ + sticker?: boolean; }; export type TelegramNetworkConfig = { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index fbf6a2173..ed7dda22a 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -128,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(), diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index c4f712e0f..639e641d0 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -30,6 +30,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record { + 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; diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index f7ddb256f..477b98280 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -4,6 +4,10 @@ import { createInboundDebouncer, resolveInboundDebounceMs, } from "../auto-reply/inbound-debounce.js"; +import { buildCommandsPaginationKeyboard } from "../auto-reply/reply/commands-info.js"; +import { buildCommandsMessagePaginated } from "../auto-reply/status.js"; +import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js"; +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { loadConfig } from "../config/config.js"; import { writeConfigFile } from "../config/io.js"; import { danger, logVerbose, warn } from "../globals.js"; @@ -17,6 +21,7 @@ import { migrateTelegramGroupConfig } from "./group-migration.js"; import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js"; import { readTelegramAllowFromStore } from "./pairing-store.js"; import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js"; +import { buildInlineKeyboard } from "./send.js"; export const registerTelegramHandlers = ({ cfg, @@ -112,11 +117,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, + }); } } @@ -315,6 +328,47 @@ export const registerTelegramHandlers = ({ } } + const paginationMatch = data.match(/^commands_page_(\d+|noop)(?::(.+))?$/); + if (paginationMatch) { + const pageValue = paginationMatch[1]; + if (pageValue === "noop") return; + + const page = Number.parseInt(pageValue, 10); + if (Number.isNaN(page) || page < 1) return; + + const agentId = paginationMatch[2]?.trim() || resolveDefaultAgentId(cfg) || undefined; + const skillCommands = listSkillCommandsForAgents({ + cfg, + agentIds: agentId ? [agentId] : undefined, + }); + const result = buildCommandsMessagePaginated(cfg, skillCommands, { + page, + surface: "telegram", + }); + + const keyboard = + result.totalPages > 1 + ? buildInlineKeyboard( + buildCommandsPaginationKeyboard(result.currentPage, result.totalPages, agentId), + ) + : undefined; + + try { + await bot.api.editMessageText( + callbackMessage.chat.id, + callbackMessage.message_id, + result.text, + keyboard ? { reply_markup: keyboard } : undefined, + ); + } catch (editErr) { + const errStr = String(editErr); + if (!errStr.includes("message is not modified")) { + throw editErr; + } + } + return; + } + const syntheticMessage: TelegramMessage = { ...callbackMessage, from: callback.from, @@ -595,7 +649,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); diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index a054943a2..f978be7c2 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -1,6 +1,12 @@ import type { Bot } from "grammy"; import { resolveAckReaction } from "../agents/identity.js"; +import { + findModelInCatalog, + loadModelCatalog, + modelSupportsVision, +} from "../agents/model-catalog.js"; +import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; import { normalizeCommandBody } from "../auto-reply/commands-registry.js"; import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js"; @@ -49,7 +55,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; @@ -94,6 +110,24 @@ type BuildTelegramMessageContextParams = { resolveTelegramGroupConfig: ResolveTelegramGroupConfig; }; +async function resolveStickerVisionSupport(params: { + cfg: ClawdbotConfig; + agentId?: string; +}): Promise { + try { + const catalog = await loadModelCatalog({ config: params.cfg }); + const defaultModel = resolveDefaultModelForAgent({ + cfg: params.cfg, + agentId: params.agentId, + }); + const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model); + if (!entry) return false; + return modelSupportsVision(entry); + } catch { + return false; + } +} + export const buildTelegramMessageContext = async ({ primaryCtx, allMedia, @@ -302,6 +336,21 @@ export const buildTelegramMessageContext = async ({ else if (msg.video) placeholder = ""; else if (msg.audio || msg.voice) placeholder = ""; else if (msg.document) placeholder = ""; + else if (msg.sticker) placeholder = ""; + + // Check if sticker has a cached description - if so, use it instead of sending the image + const cachedStickerDescription = allMedia[0]?.stickerMetadata?.cachedDescription; + const stickerSupportsVision = msg.sticker + ? await resolveStickerVisionSupport({ cfg, agentId: route.agentId }) + : false; + const stickerCacheHit = Boolean(cachedStickerDescription) && !stickerSupportsVision; + if (stickerCacheHit) { + // Format cached description with sticker context + const emoji = allMedia[0]?.stickerMetadata?.emoji; + const setName = allMedia[0]?.stickerMetadata?.setName; + const stickerContext = [emoji, setName ? `from "${setName}"` : null].filter(Boolean).join(" "); + placeholder = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${cachedStickerDescription}`; + } const locationData = extractTelegramLocation(msg); const locationText = locationData ? formatLocationText(locationData) : undefined; @@ -525,15 +574,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, diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 334c4c212..27c6a3bfa 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -1,5 +1,11 @@ // @ts-nocheck import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js"; +import { + findModelInCatalog, + loadModelCatalog, + modelSupportsVision, +} from "../agents/model-catalog.js"; +import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; import { resolveChunkMode } from "../auto-reply/chunk.js"; import { clearHistoryEntriesIfEnabled } from "../auto-reply/reply/history.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; @@ -12,6 +18,20 @@ import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { deliverReplies } from "./bot/delivery.js"; import { 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"; + +async function resolveStickerVisionSupport(cfg, agentId) { + try { + const catalog = await loadModelCatalog({ config: cfg }); + const defaultModel = resolveDefaultModelForAgent({ cfg, agentId }); + const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model); + if (!entry) return false; + return modelSupportsVision(entry); + } catch { + return false; + } +} export const dispatchTelegramMessage = async ({ context, @@ -128,6 +148,56 @@ 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 && ctxPayload.MediaPath) { + const agentDir = resolveAgentDir(cfg, route.agentId); + const stickerSupportsVision = await resolveStickerVisionSupport(cfg, route.agentId); + let description = sticker.cachedDescription ?? null; + if (!description) { + description = await describeStickerImage({ + imagePath: ctxPayload.MediaPath, + cfg, + agentDir, + agentId: route.agentId, + }); + } + if (description) { + // Format the description with sticker context + const stickerContext = [sticker.emoji, sticker.setName ? `from "${sticker.setName}"` : null] + .filter(Boolean) + .join(" "); + const formattedDesc = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${description}`; + + sticker.cachedDescription = description; + if (!stickerSupportsVision) { + // Update context to use description instead of image + ctxPayload.Body = formattedDesc; + ctxPayload.BodyForAgent = formattedDesc; + // Clear media paths so native vision doesn't process the image again + ctxPayload.MediaPath = undefined; + ctxPayload.MediaType = undefined; + ctxPayload.MediaUrl = undefined; + ctxPayload.MediaPaths = undefined; + ctxPayload.MediaUrls = undefined; + ctxPayload.MediaTypes = undefined; + } + + // Cache the description for future encounters + cacheSticker({ + fileId: sticker.fileId, + fileUniqueId: sticker.fileUniqueId, + emoji: sticker.emoji, + setName: sticker.setName, + description, + cachedAt: new Date().toISOString(), + receivedFrom: ctxPayload.From, + }); + logVerbose(`telegram: cached sticker description for ${sticker.fileUniqueId}`); + } + } + const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg, @@ -139,6 +209,7 @@ export const dispatchTelegramMessage = async ({ await flushDraft(); draftStream?.stop(); } + await deliverReplies({ replies: [payload], chatId: String(chatId), diff --git a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts index b6c1ca419..165488426 100644 --- a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts +++ b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts @@ -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; + + 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, + ) => Promise; + 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(""); + 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; + + 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, + ) => Promise; + 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; + + 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, + ) => Promise; + 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; + + 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, + ) => Promise; + 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(); diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 274f7c6a9..c2de155b0 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -93,6 +93,7 @@ const commandSpy = vi.fn(); const botCtorSpy = vi.fn(); const answerCallbackQuerySpy = vi.fn(async () => undefined); const sendChatActionSpy = vi.fn(); +const editMessageTextSpy = vi.fn(async () => ({ message_id: 88 })); const setMessageReactionSpy = vi.fn(async () => undefined); const setMyCommandsSpy = vi.fn(async () => undefined); const sendMessageSpy = vi.fn(async () => ({ message_id: 77 })); @@ -102,6 +103,7 @@ type ApiStub = { config: { use: (arg: unknown) => void }; answerCallbackQuery: typeof answerCallbackQuerySpy; sendChatAction: typeof sendChatActionSpy; + editMessageText: typeof editMessageTextSpy; setMessageReaction: typeof setMessageReactionSpy; setMyCommands: typeof setMyCommandsSpy; sendMessage: typeof sendMessageSpy; @@ -112,6 +114,7 @@ const apiStub: ApiStub = { config: { use: useSpy }, answerCallbackQuery: answerCallbackQuerySpy, sendChatAction: sendChatActionSpy, + editMessageText: editMessageTextSpy, setMessageReaction: setMessageReactionSpy, setMyCommands: setMyCommandsSpy, sendMessage: sendMessageSpy, @@ -192,6 +195,7 @@ describe("createTelegramBot", () => { sendPhotoSpy.mockReset(); setMessageReactionSpy.mockReset(); answerCallbackQuerySpy.mockReset(); + editMessageTextSpy.mockReset(); setMyCommandsSpy.mockReset(); wasSentByBot.mockReset(); middlewareUseSpy.mockReset(); @@ -424,6 +428,87 @@ describe("createTelegramBot", () => { expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-2"); }); + it("edits commands list for pagination callbacks", async () => { + onSpy.mockReset(); + listSkillCommandsForAgents.mockReset(); + + createTelegramBot({ token: "tok" }); + const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( + ctx: Record, + ) => Promise; + expect(callbackHandler).toBeDefined(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-3", + data: "commands_page_2:main", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 12, + }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(listSkillCommandsForAgents).toHaveBeenCalledWith({ + cfg: expect.any(Object), + agentIds: ["main"], + }); + expect(editMessageTextSpy).toHaveBeenCalledTimes(1); + const [chatId, messageId, text, params] = editMessageTextSpy.mock.calls[0] ?? []; + expect(chatId).toBe(1234); + expect(messageId).toBe(12); + expect(String(text)).toContain("ℹ️ Commands"); + expect(params).toEqual( + expect.objectContaining({ + reply_markup: expect.any(Object), + }), + ); + }); + + it("blocks pagination callbacks when allowlist rejects sender", async () => { + onSpy.mockReset(); + editMessageTextSpy.mockReset(); + + createTelegramBot({ + token: "tok", + config: { + channels: { + telegram: { + dmPolicy: "pairing", + capabilities: { inlineButtons: "allowlist" }, + allowFrom: [], + }, + }, + }, + }); + const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( + ctx: Record, + ) => Promise; + expect(callbackHandler).toBeDefined(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-4", + data: "commands_page_2", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 13, + }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(editMessageTextSpy).not.toHaveBeenCalled(); + expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-4"); + }); + it("wraps inbound message with Telegram envelope", async () => { const originalTz = process.env.TZ; process.env.TZ = "Europe/Vienna"; diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index c2489300c..779c0c026 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -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: "", + 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: "", + 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; diff --git a/src/telegram/bot/types.ts b/src/telegram/bot/types.ts index 1174503b4..3e106b885 100644 --- a/src/telegram/bot/types.ts +++ b/src/telegram/bot/types.ts @@ -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; +} diff --git a/src/telegram/send.returns-undefined-empty-input.test.ts b/src/telegram/send.returns-undefined-empty-input.test.ts index d086fe2a3..b6b497789 100644 --- a/src/telegram/send.returns-undefined-empty-input.test.ts +++ b/src/telegram/send.returns-undefined-empty-input.test.ts @@ -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); + }); +}); diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 92cd3ddc1..7dd79dd1f 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -619,3 +619,96 @@ function inferFilename(kind: ReturnType) { 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 { + 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 = 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 = (fn: () => Promise, 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 }; +} diff --git a/src/telegram/sticker-cache.test.ts b/src/telegram/sticker-cache.test.ts new file mode 100644 index 000000000..7fa3b6af2 --- /dev/null +++ b/src/telegram/sticker-cache.test.ts @@ -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"); + }); + }); +}); diff --git a/src/telegram/sticker-cache.ts b/src/telegram/sticker-cache.ts new file mode 100644 index 000000000..ab322e59e --- /dev/null +++ b/src/telegram/sticker-cache.ts @@ -0,0 +1,260 @@ +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 type { ModelCatalogEntry } from "../agents/model-catalog.js"; +import { + findModelInCatalog, + loadModelCatalog, + modelSupportsVision, +} from "../agents/model-catalog.js"; +import { resolveApiKeyForProvider } from "../agents/model-auth.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; +} + +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."; +const VISION_PROVIDERS = ["openai", "anthropic", "google", "minimax"] as const; + +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 { + const { imagePath, cfg, agentDir, agentId } = params; + + const defaultModel = resolveDefaultModelForAgent({ cfg, agentId }); + let activeModel = undefined as { provider: string; model: string } | undefined; + let catalog: ModelCatalogEntry[] = []; + try { + catalog = await loadModelCatalog({ config: cfg }); + const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model); + const supportsVision = modelSupportsVision(entry); + if (supportsVision) { + activeModel = { provider: defaultModel.provider, model: defaultModel.model }; + } + } catch { + // Ignore catalog failures; fall back to auto selection. + } + + const hasProviderKey = async (provider: string) => { + try { + await resolveApiKeyForProvider({ provider, cfg, agentDir }); + return true; + } catch { + return false; + } + }; + + const selectCatalogModel = (provider: string) => { + const entries = catalog.filter( + (entry) => + entry.provider.toLowerCase() === provider.toLowerCase() && modelSupportsVision(entry), + ); + if (entries.length === 0) return undefined; + const defaultId = + provider === "openai" + ? "gpt-5-mini" + : provider === "anthropic" + ? "claude-opus-4-5" + : provider === "google" + ? "gemini-3-flash-preview" + : "MiniMax-VL-01"; + const preferred = entries.find((entry) => entry.id === defaultId); + return preferred ?? entries[0]; + }; + + let resolved = null as { provider: string; model?: string } | null; + if ( + activeModel && + VISION_PROVIDERS.includes(activeModel.provider as (typeof VISION_PROVIDERS)[number]) && + (await hasProviderKey(activeModel.provider)) + ) { + resolved = activeModel; + } + + if (!resolved) { + for (const provider of VISION_PROVIDERS) { + if (!(await hasProviderKey(provider))) continue; + const entry = selectCatalogModel(provider); + if (entry) { + resolved = { provider, model: entry.id }; + break; + } + } + } + + if (!resolved) { + resolved = await resolveAutoImageModel({ + cfg, + agentDir, + activeModel, + }); + } + + if (!resolved?.model) { + logVerbose("telegram: no vision provider available for sticker description"); + return null; + } + + const { provider, model } = resolved; + 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; + } +}