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;
+ }
+}