Merge branch 'main' into nanogpt

This commit is contained in:
0xGingi 2026-01-27 05:31:47 -05:00 committed by GitHub
commit 6a2150a242
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 2474 additions and 216 deletions

View File

@ -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.

View File

@ -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 {

View File

@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.25</string>
<string>2026.1.26</string>
<key>CFBundleVersion</key>
<string>20260125</string>
<string>20260126</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>

View File

@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.25</string>
<string>2026.1.26</string>
<key>CFBundleVersion</key>
<string>20260125</string>
<string>20260126</string>
</dict>
</plist>

View File

@ -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"

View File

@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.25</string>
<string>2026.1.26</string>
<key>CFBundleVersion</key>
<string>202601250</string>
<string>202601260</string>
<key>CFBundleIconFile</key>
<string>Clawdbot</string>
<key>CFBundleURLTypes</key>

View File

@ -383,6 +383,133 @@ For message tool sends, set `asVoice: true` with a voice-compatible audio `media
}
```
## Stickers
Clawdbot supports receiving and sending Telegram stickers with intelligent caching.
### Receiving stickers
When a user sends a sticker, Clawdbot handles it based on the sticker type:
- **Static stickers (WEBP):** Downloaded and processed through vision. The sticker appears as a `<media:sticker>` placeholder in the message content.
- **Animated stickers (TGS):** Skipped (Lottie format not supported for processing).
- **Video stickers (WEBM):** Skipped (video format not supported for processing).
Template context field available when receiving stickers:
- `Sticker` — object with:
- `emoji` — emoji associated with the sticker
- `setName` — name of the sticker set
- `fileId` — Telegram file ID (send the same sticker back)
- `fileUniqueId` — stable ID for cache lookup
- `cachedDescription` — cached vision description when available
### Sticker cache
Stickers are processed through the AI's vision capabilities to generate descriptions. Since the same stickers are often sent repeatedly, Clawdbot caches these descriptions to avoid redundant API calls.
**How it works:**
1. **First encounter:** The sticker image is sent to the AI for vision analysis. The AI generates a description (e.g., "A cartoon cat waving enthusiastically").
2. **Cache storage:** The description is saved along with the sticker's file ID, emoji, and set name.
3. **Subsequent encounters:** When the same sticker is seen again, the cached description is used directly. The image is not sent to the AI.
**Cache location:** `~/.clawdbot/telegram/sticker-cache.json`
**Cache entry format:**
```json
{
"fileId": "CAACAgIAAxkBAAI...",
"fileUniqueId": "AgADBAADb6cxG2Y",
"emoji": "👋",
"setName": "CoolCats",
"description": "A cartoon cat waving enthusiastically",
"cachedAt": "2026-01-15T10:30:00.000Z"
}
```
**Benefits:**
- Reduces API costs by avoiding repeated vision calls for the same sticker
- Faster response times for cached stickers (no vision processing delay)
- Enables sticker search functionality based on cached descriptions
The cache is populated automatically as stickers are received. There is no manual cache management required.
### Sending stickers
The agent can send and search stickers using the `sticker` and `sticker-search` actions. These are disabled by default and must be enabled in config:
```json5
{
channels: {
telegram: {
actions: {
sticker: true
}
}
}
}
```
**Send a sticker:**
```json5
{
"action": "sticker",
"channel": "telegram",
"to": "123456789",
"fileId": "CAACAgIAAxkBAAI..."
}
```
Parameters:
- `fileId` (required) — the Telegram file ID of the sticker. Obtain this from `Sticker.fileId` when receiving a sticker, or from a `sticker-search` result.
- `replyTo` (optional) — message ID to reply to.
- `threadId` (optional) — message thread ID for forum topics.
**Search for stickers:**
The agent can search cached stickers by description, emoji, or set name:
```json5
{
"action": "sticker-search",
"channel": "telegram",
"query": "cat waving",
"limit": 5
}
```
Returns matching stickers from the cache:
```json5
{
"ok": true,
"count": 2,
"stickers": [
{
"fileId": "CAACAgIAAxkBAAI...",
"emoji": "👋",
"description": "A cartoon cat waving enthusiastically",
"setName": "CoolCats"
}
]
}
```
The search uses fuzzy matching across description text, emoji characters, and set names.
**Example with threading:**
```json5
{
"action": "sticker",
"channel": "telegram",
"to": "-1001234567890",
"fileId": "CAACAgIAAxkBAAI...",
"replyTo": 42,
"threadId": 123
}
```
## Streaming (drafts)
Telegram can stream **draft bubbles** while the agent is generating a response.
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
@ -420,7 +547,7 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti
- Tool: `telegram` with `react` action (`chatId`, `messageId`, `emoji`).
- Tool: `telegram` with `deleteMessage` action (`chatId`, `messageId`).
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
- Tool gating: `channels.telegram.actions.reactions`, `channels.telegram.actions.sendMessage`, `channels.telegram.actions.deleteMessage` (default: enabled).
- Tool gating: `channels.telegram.actions.reactions`, `channels.telegram.actions.sendMessage`, `channels.telegram.actions.deleteMessage` (default: enabled), and `channels.telegram.actions.sticker` (default: disabled).
## Reaction notifications
@ -537,6 +664,7 @@ Provider options:
- `channels.telegram.actions.reactions`: gate Telegram tool reactions.
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
- `channels.telegram.actions.sticker`: gate Telegram sticker actions — send and search (default: false).
- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `own` when not set).
- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `minimal` when not set).

View File

@ -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/)
<script>
// Best-effort client-side redirect for Mintlify/Next.
window.location.replace("/security/formal-verification/");
</script>

View File

@ -0,0 +1,107 @@
---
title: Formal Verification (Security Models)
summary: Machine-checked security models for Clawdbots highest-risk paths.
permalink: /gateway/security/formal-verification/
---
# Formal Verification (Security Models)
This page tracks Clawdbots **formal security models** (TLA+/TLC today; more as needed).
**Goal (north star):** provide a machine-checked argument that Clawdbot enforces its
intended security policy (authorization, session isolation, tool gating, and
misconfiguration safety), under explicit assumptions.
**What this is (today):** an executable, attacker-driven **security regression suite**:
- Each claim has a runnable model-check over a finite state space.
- Many claims have a paired **negative model** that produces a counterexample trace for a realistic bug class.
**What this is not (yet):** a proof that “Clawdbot is secure in all respects” or that the full TypeScript implementation is correct.
## Where the models live
Models are maintained in a separate repo: [vignesh07/clawdbot-formal-models](https://github.com/vignesh07/clawdbot-formal-models).
## Important caveats
- These are **models**, not the full TypeScript implementation. Drift between model and code is possible.
- Results are bounded by the state space explored by TLC; “green” does not imply security beyond the modeled assumptions and bounds.
- Some claims rely on explicit environmental assumptions (e.g., correct deployment, correct configuration inputs).
## Reproducing results
Today, results are reproduced by cloning the models repo locally and running TLC (see below). A future iteration could offer:
- CI-run models with public artifacts (counterexample traces, run logs)
- a hosted “run this model” workflow for small, bounded checks
Getting started:
```bash
git clone https://github.com/vignesh07/clawdbot-formal-models
cd clawdbot-formal-models
# Java 11+ required (TLC runs on the JVM).
# The repo vendors a pinned `tla2tools.jar` (TLA+ tools) and provides `bin/tlc` + Make targets.
make <target>
```
### Gateway exposure and open gateway misconfiguration
**Claim:** binding beyond loopback without auth can make remote compromise possible / increases exposure; token/password blocks unauth attackers (per the model assumptions).
- Green runs:
- `make gateway-exposure-v2`
- `make gateway-exposure-v2-protected`
- Red (expected):
- `make gateway-exposure-v2-negative`
See also: `docs/gateway-exposure-matrix.md` in the models repo.
### Nodes.run pipeline (highest-risk capability)
**Claim:** `nodes.run` requires (a) node command allowlist plus declared commands and (b) live approval when configured; approvals are tokenized to prevent replay (in the model).
- Green runs:
- `make nodes-pipeline`
- `make approvals-token`
- Red (expected):
- `make nodes-pipeline-negative`
- `make approvals-token-negative`
### Pairing store (DM gating)
**Claim:** pairing requests respect TTL and pending-request caps.
- Green runs:
- `make pairing`
- `make pairing-cap`
- Red (expected):
- `make pairing-negative`
- `make pairing-cap-negative`
### Ingress gating (mentions + control-command bypass)
**Claim:** in group contexts requiring mention, an unauthorized “control command” cannot bypass mention gating.
- Green:
- `make ingress-gating`
- Red (expected):
- `make ingress-gating-negative`
### Routing/session-key isolation
**Claim:** DMs from distinct peers do not collapse into the same session unless explicitly linked/configured.
- Green:
- `make routing-isolation`
- Red (expected):
- `make routing-isolation-negative`
## Roadmap
Next models to deepen fidelity:
- Pairing store concurrency/locking/idempotency
- Provider-specific ingress preflight modeling
- Routing identity-links + dmScope variants + binding precedence
- Gateway auth conformance (proxy/tailscale specifics)

View File

@ -185,7 +185,7 @@ cat > /data/clawdbot.json << 'EOF'
"bind": "auto"
},
"meta": {
"lastTouchedVersion": "2026.1.25"
"lastTouchedVersion": "2026.1.26"
}
}
EOF

View File

@ -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: <Developer Name> (<TEAMID>)" \
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 "<apple-id>" --team-id "<team-id>" --password "<app-specific-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: <Developer Name> (<TEAMID>)" \
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.

View File

@ -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`.

View File

@ -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
🦞💙

View File

@ -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"
}

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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"
}
}

View File

@ -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": {

View File

@ -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"
}

View File

@ -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": {

View File

@ -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": {

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}
}

View File

@ -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": {

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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"
}

View File

@ -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": {

View File

@ -1,6 +1,6 @@
# Changelog
## 2026.1.25
## 2026.1.26
### Changes
- Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deepmerges with core).

View File

@ -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": {

View File

@ -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": {

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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",

View File

@ -0,0 +1,16 @@
{
"name": "clawdbot",
"version": "2026.1.26",
"type": "module",
"description": "Compatibility shim that forwards to moltbot",
"exports": {
".": "./index.js",
"./plugin-sdk": "./plugin-sdk/index.js"
},
"bin": {
"clawdbot": "./bin/clawdbot.js"
},
"dependencies": {
"moltbot": "workspace:*"
}
}

View File

@ -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" } },

View File

@ -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}`);
}

View File

@ -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",

View File

@ -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 = {

View File

@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { buildCommandsPaginationKeyboard } from "./commands-info.js";
describe("buildCommandsPaginationKeyboard", () => {
it("adds agent id to callback data when provided", () => {
const keyboard = buildCommandsPaginationKeyboard(2, 3, "agent-main");
expect(keyboard[0]).toEqual([
{ text: "◀ Prev", callback_data: "commands_page_1:agent-main" },
{ text: "2/3", callback_data: "commands_page_noop:agent-main" },
{ text: "Next ▶", callback_data: "commands_page_3:agent-main" },
]);
});
});

View File

@ -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<Array<{ text: string; callback_data: string }>> {
const buttons: Array<{ text: string; callback_data: string }> = [];
const suffix = agentId ? `:${agentId}` : "";
if (currentPage > 1) {
buttons.push({
text: "◀ Prev",
callback_data: `commands_page_${currentPage - 1}${suffix}`,
});
}
buttons.push({
text: `${currentPage}/${totalPages}`,
callback_data: `commands_page_noop${suffix}`,
});
if (currentPage < totalPages) {
buttons.push({
text: "Next ▶",
callback_data: `commands_page_${currentPage + 1}${suffix}`,
});
}
return [buttons];
}
export const handleStatusCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const statusRequested =

View File

@ -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 <name> [input]");
expect(text).toContain("Skills");
expect(text).toContain("/skill <name> [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");
});
});

View File

@ -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<CommandCategory, string> = {
session: "Session",
options: "Options",
status: "Status",
management: "Management",
media: "Media",
tools: "Tools",
docks: "Docks",
};
const CATEGORY_ORDER: CommandCategory[] = [
"session",
"options",
"status",
"management",
"media",
"tools",
"docks",
];
function groupCommandsByCategory(
commands: ChatCommandDefinition[],
): Map<CommandCategory, ChatCommandDefinition[]> {
const grouped = new Map<CommandCategory, ChatCommandDefinition[]>();
for (const category of CATEGORY_ORDER) {
grouped.set(category, []);
}
for (const command of commands) {
const category = command.category ?? "tools";
const list = grouped.get(category) ?? [];
list.push(command);
grouped.set(category, list);
}
return grouped;
}
export function buildHelpMessage(cfg?: ClawdbotConfig): string {
const options = [
"/think <level>",
"/verbose on|full|off",
"/reasoning on|off",
"/elevated on|off|ask|full",
"/model <id>",
"/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 <name> [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 <level>", "/model <id>", "/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 <name> [input]");
lines.push("");
lines.push("More: /commands for full list");
return lines.join("\n");
}
const COMMANDS_PER_PAGE = 8;
export type CommandsMessageOptions = {
page?: number;
surface?: string;
};
export type CommandsMessageResult = {
text: string;
totalPages: number;
currentPage: number;
hasNext: boolean;
hasPrev: boolean;
};
function formatCommandEntry(command: ChatCommandDefinition): string {
const primary = command.nativeName
? `/${command.nativeName}`
: command.textAliases[0]?.trim() || `/${command.key}`;
const seen = new Set<string>();
const aliases = command.textAliases
.map((alias) => alias.trim())
.filter(Boolean)
.filter((alias) => alias.toLowerCase() !== primary.toLowerCase())
.filter((alias) => {
const key = alias.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const aliasLabel = aliases.length ? ` (${aliases.join(", ")})` : "";
const scopeLabel = command.scope === "text" ? " [text]" : "";
return `${primary}${aliasLabel}${scopeLabel} - ${command.description}`;
}
type CommandsListItem = {
label: string;
text: string;
};
function buildCommandItems(
commands: ChatCommandDefinition[],
pluginCommands: ReturnType<typeof listPluginCommands>,
): CommandsListItem[] {
const grouped = groupCommandsByCategory(commands);
const items: CommandsListItem[] = [];
for (const category of CATEGORY_ORDER) {
const categoryCommands = grouped.get(category) ?? [];
if (categoryCommands.length === 0) continue;
const label = CATEGORY_LABELS[category];
for (const command of categoryCommands) {
items.push({ label, text: formatCommandEntry(command) });
}
}
for (const command of pluginCommands) {
const pluginLabel = command.pluginId ? ` (${command.pluginId})` : "";
items.push({
label: "Plugins",
text: `/${command.name}${pluginLabel} - ${command.description}`,
});
}
return items;
}
function formatCommandList(items: CommandsListItem[]): string {
const lines: string[] = [];
let currentLabel: string | null = null;
for (const item of items) {
if (item.label !== currentLabel) {
if (lines.length > 0) lines.push("");
lines.push(item.label);
currentLabel = item.label;
}
lines.push(` ${item.text}`);
}
return lines.join("\n");
}
export function buildCommandsMessage(
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<string>();
const aliases = command.textAliases
.map((alias) => alias.trim())
.filter(Boolean)
.filter((alias) => alias.toLowerCase() !== primary.toLowerCase())
.filter((alias) => {
const key = alias.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const aliasLabel = aliases.length ? ` (aliases: ${aliases.join(", ")})` : "";
const scopeLabel = command.scope === "text" ? " (text-only)" : "";
lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`);
}
const pluginCommands = listPluginCommands();
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,
};
}

View File

@ -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). */

View File

@ -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;

View File

@ -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}.`);
},
};

View File

@ -25,6 +25,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
"thread-reply",
"search",
"sticker",
"sticker-search",
"member-info",
"role-info",
"emoji-list",

View File

@ -16,6 +16,8 @@ export type TelegramActionConfig = {
sendMessage?: boolean;
deleteMessage?: boolean;
editMessage?: boolean;
/** Enable sticker actions (send and search). */
sticker?: boolean;
};
export type TelegramNetworkConfig = {

View File

@ -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(),

View File

@ -30,6 +30,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
"thread-reply": "to",
search: "none",
sticker: "to",
"sticker-search": "none",
"member-info": "none",
"role-info": "none",
"emoji-list": "none",

View File

@ -412,6 +412,39 @@ async function resolveAutoEntries(params: {
return [];
}
export async function resolveAutoImageModel(params: {
cfg: ClawdbotConfig;
agentDir?: string;
activeModel?: ActiveMediaModel;
}): Promise<ActiveMediaModel | null> {
const providerRegistry = buildProviderRegistry();
const toActive = (entry: MediaUnderstandingModelConfig | null): ActiveMediaModel | null => {
if (!entry || entry.type === "cli") return null;
const provider = entry.provider;
if (!provider) return null;
const model = entry.model ?? DEFAULT_IMAGE_MODELS[provider];
if (!model) return null;
return { provider, model };
};
const activeEntry = await resolveActiveModelEntry({
cfg: params.cfg,
agentDir: params.agentDir,
providerRegistry,
capability: "image",
activeModel: params.activeModel,
});
const resolvedActive = toActive(activeEntry);
if (resolvedActive) return resolvedActive;
const keyEntry = await resolveKeyEntry({
cfg: params.cfg,
agentDir: params.agentDir,
providerRegistry,
capability: "image",
activeModel: params.activeModel,
});
return toActive(keyEntry);
}
async function resolveActiveModelEntry(params: {
cfg: ClawdbotConfig;
agentDir?: string;

View File

@ -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);

View File

@ -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<boolean> {
try {
const catalog = await loadModelCatalog({ config: params.cfg });
const defaultModel = resolveDefaultModelForAgent({
cfg: params.cfg,
agentId: params.agentId,
});
const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model);
if (!entry) return false;
return modelSupportsVision(entry);
} catch {
return false;
}
}
export const buildTelegramMessageContext = async ({
primaryCtx,
allMedia,
@ -302,6 +336,21 @@ export const buildTelegramMessageContext = async ({
else if (msg.video) placeholder = "<media:video>";
else if (msg.audio || msg.voice) placeholder = "<media:audio>";
else if (msg.document) placeholder = "<media:document>";
else if (msg.sticker) placeholder = "<media:sticker>";
// Check if sticker has a cached description - if so, use it instead of sending the image
const cachedStickerDescription = allMedia[0]?.stickerMetadata?.cachedDescription;
const 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,

View File

@ -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),

View File

@ -7,6 +7,9 @@ const middlewareUseSpy = vi.fn();
const onSpy = vi.fn();
const stopSpy = vi.fn();
const sendChatActionSpy = vi.fn();
const cacheStickerSpy = vi.fn();
const getCachedStickerSpy = vi.fn();
const describeStickerImageSpy = vi.fn();
type ApiStub = {
config: { use: (arg: unknown) => void };
@ -79,6 +82,12 @@ vi.mock("../config/sessions.js", async (importOriginal) => {
};
});
vi.mock("./sticker-cache.js", () => ({
cacheSticker: (...args: unknown[]) => cacheStickerSpy(...args),
getCachedSticker: (...args: unknown[]) => getCachedStickerSpy(...args),
describeStickerImage: (...args: unknown[]) => describeStickerImageSpy(...args),
}));
vi.mock("./pairing-store.js", () => ({
readTelegramAllowFromStore: vi.fn(async () => [] as string[]),
upsertTelegramPairingRequest: vi.fn(async () => ({
@ -405,6 +414,290 @@ describe("telegram media groups", () => {
);
});
describe("telegram stickers", () => {
const STICKER_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 20_000;
beforeEach(() => {
cacheStickerSpy.mockReset();
getCachedStickerSpy.mockReset();
describeStickerImageSpy.mockReset();
});
it(
"downloads static sticker (WEBP) and includes sticker metadata",
async () => {
const { createTelegramBot } = await import("./bot.js");
const replyModule = await import("../auto-reply/reply.js");
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
onSpy.mockReset();
replySpy.mockReset();
sendChatActionSpy.mockReset();
const runtimeLog = vi.fn();
const runtimeError = vi.fn();
createTelegramBot({
token: "tok",
runtime: {
log: runtimeLog,
error: runtimeError,
exit: () => {
throw new Error("exit");
},
},
});
const handler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
expect(handler).toBeDefined();
const fetchSpy = vi.spyOn(globalThis, "fetch" as never).mockResolvedValueOnce({
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => "image/webp" },
arrayBuffer: async () => new Uint8Array([0x52, 0x49, 0x46, 0x46]).buffer, // RIFF header
} as Response);
await handler({
message: {
message_id: 100,
chat: { id: 1234, type: "private" },
sticker: {
file_id: "sticker_file_id_123",
file_unique_id: "sticker_unique_123",
type: "regular",
width: 512,
height: 512,
is_animated: false,
is_video: false,
emoji: "🎉",
set_name: "TestStickerPack",
},
date: 1736380800,
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ file_path: "stickers/sticker.webp" }),
});
expect(runtimeError).not.toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalledWith(
"https://api.telegram.org/file/bottok/stickers/sticker.webp",
);
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toContain("<media:sticker>");
expect(payload.Sticker?.emoji).toBe("🎉");
expect(payload.Sticker?.setName).toBe("TestStickerPack");
expect(payload.Sticker?.fileId).toBe("sticker_file_id_123");
fetchSpy.mockRestore();
},
STICKER_TEST_TIMEOUT_MS,
);
it(
"refreshes cached sticker metadata on cache hit",
async () => {
const { createTelegramBot } = await import("./bot.js");
const replyModule = await import("../auto-reply/reply.js");
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
onSpy.mockReset();
replySpy.mockReset();
sendChatActionSpy.mockReset();
getCachedStickerSpy.mockReturnValue({
fileId: "old_file_id",
fileUniqueId: "sticker_unique_456",
emoji: "😴",
setName: "OldSet",
description: "Cached description",
cachedAt: "2026-01-20T10:00:00.000Z",
});
const runtimeError = vi.fn();
createTelegramBot({
token: "tok",
runtime: {
log: vi.fn(),
error: runtimeError,
exit: () => {
throw new Error("exit");
},
},
});
const handler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
expect(handler).toBeDefined();
const fetchSpy = vi.spyOn(globalThis, "fetch" as never).mockResolvedValueOnce({
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => "image/webp" },
arrayBuffer: async () => new Uint8Array([0x52, 0x49, 0x46, 0x46]).buffer,
} as Response);
await handler({
message: {
message_id: 103,
chat: { id: 1234, type: "private" },
sticker: {
file_id: "new_file_id",
file_unique_id: "sticker_unique_456",
type: "regular",
width: 512,
height: 512,
is_animated: false,
is_video: false,
emoji: "🔥",
set_name: "NewSet",
},
date: 1736380800,
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ file_path: "stickers/sticker.webp" }),
});
expect(runtimeError).not.toHaveBeenCalled();
expect(cacheStickerSpy).toHaveBeenCalledWith(
expect.objectContaining({
fileId: "new_file_id",
emoji: "🔥",
setName: "NewSet",
}),
);
const payload = replySpy.mock.calls[0][0];
expect(payload.Sticker?.fileId).toBe("new_file_id");
expect(payload.Sticker?.cachedDescription).toBe("Cached description");
fetchSpy.mockRestore();
},
STICKER_TEST_TIMEOUT_MS,
);
it(
"skips animated stickers (TGS format)",
async () => {
const { createTelegramBot } = await import("./bot.js");
const replyModule = await import("../auto-reply/reply.js");
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
onSpy.mockReset();
replySpy.mockReset();
const runtimeError = vi.fn();
const fetchSpy = vi.spyOn(globalThis, "fetch" as never);
createTelegramBot({
token: "tok",
runtime: {
log: vi.fn(),
error: runtimeError,
exit: () => {
throw new Error("exit");
},
},
});
const handler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
expect(handler).toBeDefined();
await handler({
message: {
message_id: 101,
chat: { id: 1234, type: "private" },
sticker: {
file_id: "animated_sticker_id",
file_unique_id: "animated_unique",
type: "regular",
width: 512,
height: 512,
is_animated: true, // TGS format
is_video: false,
emoji: "😎",
set_name: "AnimatedPack",
},
date: 1736380800,
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ file_path: "stickers/animated.tgs" }),
});
// Should not attempt to download animated stickers
expect(fetchSpy).not.toHaveBeenCalled();
// Should still process the message (as text-only, no media)
expect(replySpy).not.toHaveBeenCalled(); // No text content, so no reply generated
expect(runtimeError).not.toHaveBeenCalled();
fetchSpy.mockRestore();
},
STICKER_TEST_TIMEOUT_MS,
);
it(
"skips video stickers (WEBM format)",
async () => {
const { createTelegramBot } = await import("./bot.js");
const replyModule = await import("../auto-reply/reply.js");
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
onSpy.mockReset();
replySpy.mockReset();
const runtimeError = vi.fn();
const fetchSpy = vi.spyOn(globalThis, "fetch" as never);
createTelegramBot({
token: "tok",
runtime: {
log: vi.fn(),
error: runtimeError,
exit: () => {
throw new Error("exit");
},
},
});
const handler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
expect(handler).toBeDefined();
await handler({
message: {
message_id: 102,
chat: { id: 1234, type: "private" },
sticker: {
file_id: "video_sticker_id",
file_unique_id: "video_unique",
type: "regular",
width: 512,
height: 512,
is_animated: false,
is_video: true, // WEBM format
emoji: "🎬",
set_name: "VideoPack",
},
date: 1736380800,
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ file_path: "stickers/video.webm" }),
});
// Should not attempt to download video stickers
expect(fetchSpy).not.toHaveBeenCalled();
expect(replySpy).not.toHaveBeenCalled();
expect(runtimeError).not.toHaveBeenCalled();
fetchSpy.mockRestore();
},
STICKER_TEST_TIMEOUT_MS,
);
});
describe("telegram text fragments", () => {
beforeEach(() => {
vi.useFakeTimers();

View File

@ -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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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";

View File

@ -21,7 +21,8 @@ import { loadWebMedia } from "../../web/media.js";
import { buildInlineKeyboard } from "../send.js";
import { resolveTelegramVoiceSend } from "../voice.js";
import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js";
import type { TelegramContext } from "./types.js";
import type { StickerMetadata, TelegramContext } from "./types.js";
import { cacheSticker, getCachedSticker } from "../sticker-cache.js";
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
@ -261,8 +262,91 @@ export async function resolveMedia(
maxBytes: number,
token: string,
proxyFetch?: typeof fetch,
): Promise<{ path: string; contentType?: string; placeholder: string } | null> {
): Promise<{
path: string;
contentType?: string;
placeholder: string;
stickerMetadata?: StickerMetadata;
} | null> {
const msg = ctx.message;
// Handle stickers separately - only static stickers (WEBP) are supported
if (msg.sticker) {
const sticker = msg.sticker;
// Skip animated (TGS) and video (WEBM) stickers - only static WEBP supported
if (sticker.is_animated || sticker.is_video) {
logVerbose("telegram: skipping animated/video sticker (only static stickers supported)");
return null;
}
if (!sticker.file_id) return null;
try {
const file = await ctx.getFile();
if (!file.file_path) {
logVerbose("telegram: getFile returned no file_path for sticker");
return null;
}
const fetchImpl = proxyFetch ?? globalThis.fetch;
if (!fetchImpl) {
logVerbose("telegram: fetch not available for sticker download");
return null;
}
const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
const fetched = await fetchRemoteMedia({
url,
fetchImpl,
filePathHint: file.file_path,
});
const saved = await saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes);
// Check sticker cache for existing description
const cached = sticker.file_unique_id ? getCachedSticker(sticker.file_unique_id) : null;
if (cached) {
logVerbose(`telegram: sticker cache hit for ${sticker.file_unique_id}`);
const fileId = sticker.file_id ?? cached.fileId;
const emoji = sticker.emoji ?? cached.emoji;
const setName = sticker.set_name ?? cached.setName;
if (fileId !== cached.fileId || emoji !== cached.emoji || setName !== cached.setName) {
// Refresh cached sticker metadata on hits so sends/searches use latest file_id.
cacheSticker({
...cached,
fileId,
emoji,
setName,
});
}
return {
path: saved.path,
contentType: saved.contentType,
placeholder: "<media:sticker>",
stickerMetadata: {
emoji,
setName,
fileId,
fileUniqueId: sticker.file_unique_id,
cachedDescription: cached.description,
},
};
}
// Cache miss - return metadata for vision processing
return {
path: saved.path,
contentType: saved.contentType,
placeholder: "<media:sticker>",
stickerMetadata: {
emoji: sticker.emoji ?? undefined,
setName: sticker.set_name ?? undefined,
fileId: sticker.file_id,
fileUniqueId: sticker.file_unique_id,
},
};
} catch (err) {
logVerbose(`telegram: failed to process sticker: ${String(err)}`);
return null;
}
}
const m =
msg.photo?.[msg.photo.length - 1] ?? msg.video ?? msg.document ?? msg.audio ?? msg.voice;
if (!m?.file_id) return null;

View File

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

View File

@ -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);
});
});

View File

@ -619,3 +619,96 @@ function inferFilename(kind: ReturnType<typeof mediaKindFromMime>) {
return "file.bin";
}
}
type TelegramStickerOpts = {
token?: string;
accountId?: string;
verbose?: boolean;
api?: Bot["api"];
retry?: RetryConfig;
/** Message ID to reply to (for threading) */
replyToMessageId?: number;
/** Forum topic thread ID (for forum supergroups) */
messageThreadId?: number;
};
/**
* Send a sticker to a Telegram chat by file_id.
* @param to - Chat ID or username (e.g., "123456789" or "@username")
* @param fileId - Telegram file_id of the sticker to send
* @param opts - Optional configuration
*/
export async function sendStickerTelegram(
to: string,
fileId: string,
opts: TelegramStickerOpts = {},
): Promise<TelegramSendResult> {
if (!fileId?.trim()) {
throw new Error("Telegram sticker file_id is required");
}
const cfg = loadConfig();
const account = resolveTelegramAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveToken(opts.token, account);
const target = parseTelegramTarget(to);
const chatId = normalizeChatId(target.chatId);
const client = resolveTelegramClientOptions(account);
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
const messageThreadId =
opts.messageThreadId != null ? opts.messageThreadId : target.messageThreadId;
const threadIdParams = buildTelegramThreadParams(messageThreadId);
const threadParams: Record<string, number> = threadIdParams ? { ...threadIdParams } : {};
if (opts.replyToMessageId != null) {
threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId);
}
const hasThreadParams = Object.keys(threadParams).length > 0;
const request = createTelegramRetryRunner({
retry: opts.retry,
configRetry: account.config.retry,
verbose: opts.verbose,
});
const logHttpError = createTelegramHttpLogger(cfg);
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
request(fn, label).catch((err) => {
logHttpError(label ?? "request", err);
throw err;
});
const wrapChatNotFound = (err: unknown) => {
if (!/400: Bad Request: chat not found/i.test(formatErrorMessage(err))) return err;
return new Error(
[
`Telegram send failed: chat not found (chat_id=${chatId}).`,
"Likely: bot not started in DM, bot removed from group/channel, group migrated (new -100… id), or wrong bot token.",
`Input was: ${JSON.stringify(to)}.`,
].join(" "),
);
};
const stickerParams = hasThreadParams ? threadParams : undefined;
const result = await requestWithDiag(
() => api.sendSticker(chatId, fileId.trim(), stickerParams),
"sticker",
).catch((err) => {
throw wrapChatNotFound(err);
});
const messageId = String(result?.message_id ?? "unknown");
const resolvedChatId = String(result?.chat?.id ?? chatId);
if (result?.message_id) {
recordSentMessage(chatId, result.message_id);
}
recordChannelActivity({
channel: "telegram",
accountId: account.accountId,
direction: "outbound",
});
return { messageId, chatId: resolvedChatId };
}

View File

@ -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");
});
});
});

View File

@ -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<string, CachedSticker>;
}
function loadCache(): StickerCache {
const data = loadJsonFile(CACHE_FILE);
if (!data || typeof data !== "object") {
return { version: CACHE_VERSION, stickers: {} };
}
const cache = data as StickerCache;
if (cache.version !== CACHE_VERSION) {
// Future: handle migration if needed
return { version: CACHE_VERSION, stickers: {} };
}
return cache;
}
function saveCache(cache: StickerCache): void {
saveJsonFile(CACHE_FILE, cache);
}
/**
* Get a cached sticker by its unique ID.
*/
export function getCachedSticker(fileUniqueId: string): CachedSticker | null {
const cache = loadCache();
return cache.stickers[fileUniqueId] ?? null;
}
/**
* Add or update a sticker in the cache.
*/
export function cacheSticker(sticker: CachedSticker): void {
const cache = loadCache();
cache.stickers[sticker.fileUniqueId] = sticker;
saveCache(cache);
}
/**
* Search cached stickers by text query (fuzzy match on description + emoji + setName).
*/
export function searchStickers(query: string, limit = 10): CachedSticker[] {
const cache = loadCache();
const queryLower = query.toLowerCase();
const results: Array<{ sticker: CachedSticker; score: number }> = [];
for (const sticker of Object.values(cache.stickers)) {
let score = 0;
const descLower = sticker.description.toLowerCase();
// Exact substring match in description
if (descLower.includes(queryLower)) {
score += 10;
}
// Word-level matching
const queryWords = queryLower.split(/\s+/).filter(Boolean);
const descWords = descLower.split(/\s+/);
for (const qWord of queryWords) {
if (descWords.some((dWord) => dWord.includes(qWord))) {
score += 5;
}
}
// Emoji match
if (sticker.emoji && query.includes(sticker.emoji)) {
score += 8;
}
// Set name match
if (sticker.setName?.toLowerCase().includes(queryLower)) {
score += 3;
}
if (score > 0) {
results.push({ sticker, score });
}
}
return results
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map((r) => r.sticker);
}
/**
* Get all cached stickers (for debugging/listing).
*/
export function getAllCachedStickers(): CachedSticker[] {
const cache = loadCache();
return Object.values(cache.stickers);
}
/**
* Get cache statistics.
*/
export function getCacheStats(): { count: number; oldestAt?: string; newestAt?: string } {
const cache = loadCache();
const stickers = Object.values(cache.stickers);
if (stickers.length === 0) {
return { count: 0 };
}
const sorted = [...stickers].sort(
(a, b) => new Date(a.cachedAt).getTime() - new Date(b.cachedAt).getTime(),
);
return {
count: stickers.length,
oldestAt: sorted[0]?.cachedAt,
newestAt: sorted[sorted.length - 1]?.cachedAt,
};
}
const STICKER_DESCRIPTION_PROMPT =
"Describe this sticker image in 1-2 sentences. Focus on what the sticker depicts (character, object, action, emotion). Be concise and objective.";
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<string | null> {
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;
}
}