Merge branch 'main' into nanogpt
This commit is contained in:
commit
6a2150a242
@ -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.
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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).
|
||||
|
||||
|
||||
@ -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>
|
||||
107
docs/gateway/security/formal-verification.md
Normal file
107
docs/gateway/security/formal-verification.md
Normal file
@ -0,0 +1,107 @@
|
||||
---
|
||||
title: Formal Verification (Security Models)
|
||||
summary: Machine-checked security models for Clawdbot’s highest-risk paths.
|
||||
permalink: /gateway/security/formal-verification/
|
||||
---
|
||||
|
||||
# Formal Verification (Security Models)
|
||||
|
||||
This page tracks Clawdbot’s **formal security models** (TLA+/TLC today; more as needed).
|
||||
|
||||
**Goal (north star):** provide a machine-checked argument that Clawdbot enforces its
|
||||
intended security policy (authorization, session isolation, tool gating, and
|
||||
misconfiguration safety), under explicit assumptions.
|
||||
|
||||
**What this is (today):** an executable, attacker-driven **security regression suite**:
|
||||
- Each claim has a runnable model-check over a finite state space.
|
||||
- Many claims have a paired **negative model** that produces a counterexample trace for a realistic bug class.
|
||||
|
||||
**What this is not (yet):** a proof that “Clawdbot is secure in all respects” or that the full TypeScript implementation is correct.
|
||||
|
||||
## Where the models live
|
||||
|
||||
Models are maintained in a separate repo: [vignesh07/clawdbot-formal-models](https://github.com/vignesh07/clawdbot-formal-models).
|
||||
|
||||
## Important caveats
|
||||
|
||||
- These are **models**, not the full TypeScript implementation. Drift between model and code is possible.
|
||||
- Results are bounded by the state space explored by TLC; “green” does not imply security beyond the modeled assumptions and bounds.
|
||||
- Some claims rely on explicit environmental assumptions (e.g., correct deployment, correct configuration inputs).
|
||||
|
||||
## Reproducing results
|
||||
|
||||
Today, results are reproduced by cloning the models repo locally and running TLC (see below). A future iteration could offer:
|
||||
- CI-run models with public artifacts (counterexample traces, run logs)
|
||||
- a hosted “run this model” workflow for small, bounded checks
|
||||
|
||||
Getting started:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/vignesh07/clawdbot-formal-models
|
||||
cd clawdbot-formal-models
|
||||
|
||||
# Java 11+ required (TLC runs on the JVM).
|
||||
# The repo vendors a pinned `tla2tools.jar` (TLA+ tools) and provides `bin/tlc` + Make targets.
|
||||
|
||||
make <target>
|
||||
```
|
||||
|
||||
### Gateway exposure and open gateway misconfiguration
|
||||
|
||||
**Claim:** binding beyond loopback without auth can make remote compromise possible / increases exposure; token/password blocks unauth attackers (per the model assumptions).
|
||||
|
||||
- Green runs:
|
||||
- `make gateway-exposure-v2`
|
||||
- `make gateway-exposure-v2-protected`
|
||||
- Red (expected):
|
||||
- `make gateway-exposure-v2-negative`
|
||||
|
||||
See also: `docs/gateway-exposure-matrix.md` in the models repo.
|
||||
|
||||
### Nodes.run pipeline (highest-risk capability)
|
||||
|
||||
**Claim:** `nodes.run` requires (a) node command allowlist plus declared commands and (b) live approval when configured; approvals are tokenized to prevent replay (in the model).
|
||||
|
||||
- Green runs:
|
||||
- `make nodes-pipeline`
|
||||
- `make approvals-token`
|
||||
- Red (expected):
|
||||
- `make nodes-pipeline-negative`
|
||||
- `make approvals-token-negative`
|
||||
|
||||
### Pairing store (DM gating)
|
||||
|
||||
**Claim:** pairing requests respect TTL and pending-request caps.
|
||||
|
||||
- Green runs:
|
||||
- `make pairing`
|
||||
- `make pairing-cap`
|
||||
- Red (expected):
|
||||
- `make pairing-negative`
|
||||
- `make pairing-cap-negative`
|
||||
|
||||
### Ingress gating (mentions + control-command bypass)
|
||||
|
||||
**Claim:** in group contexts requiring mention, an unauthorized “control command” cannot bypass mention gating.
|
||||
|
||||
- Green:
|
||||
- `make ingress-gating`
|
||||
- Red (expected):
|
||||
- `make ingress-gating-negative`
|
||||
|
||||
### Routing/session-key isolation
|
||||
|
||||
**Claim:** DMs from distinct peers do not collapse into the same session unless explicitly linked/configured.
|
||||
|
||||
- Green:
|
||||
- `make routing-isolation`
|
||||
- Red (expected):
|
||||
- `make routing-isolation-negative`
|
||||
|
||||
## Roadmap
|
||||
|
||||
Next models to deepen fidelity:
|
||||
- Pairing store concurrency/locking/idempotency
|
||||
- Provider-specific ingress preflight modeling
|
||||
- Routing identity-links + dmScope variants + binding precedence
|
||||
- Gateway auth conformance (proxy/tailscale specifics)
|
||||
@ -185,7 +185,7 @@ cat > /data/clawdbot.json << 'EOF'
|
||||
"bind": "auto"
|
||||
},
|
||||
"meta": {
|
||||
"lastTouchedVersion": "2026.1.25"
|
||||
"lastTouchedVersion": "2026.1.26"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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
|
||||
|
||||
🦞💙
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.25
|
||||
## 2026.1.26
|
||||
|
||||
### Changes
|
||||
- Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core).
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
16
packages/clawdbot/package.json
Normal file
16
packages/clawdbot/package.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "clawdbot",
|
||||
"version": "2026.1.26",
|
||||
"type": "module",
|
||||
"description": "Compatibility shim that forwards to moltbot",
|
||||
"exports": {
|
||||
".": "./index.js",
|
||||
"./plugin-sdk": "./plugin-sdk/index.js"
|
||||
},
|
||||
"bin": {
|
||||
"clawdbot": "./bin/clawdbot.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"moltbot": "workspace:*"
|
||||
}
|
||||
}
|
||||
@ -8,12 +8,17 @@ const sendMessageTelegram = vi.fn(async () => ({
|
||||
messageId: "789",
|
||||
chatId: "123",
|
||||
}));
|
||||
const sendStickerTelegram = vi.fn(async () => ({
|
||||
messageId: "456",
|
||||
chatId: "123",
|
||||
}));
|
||||
const deleteMessageTelegram = vi.fn(async () => ({ ok: true }));
|
||||
const originalToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
|
||||
vi.mock("../../telegram/send.js", () => ({
|
||||
reactMessageTelegram: (...args: unknown[]) => reactMessageTelegram(...args),
|
||||
sendMessageTelegram: (...args: unknown[]) => sendMessageTelegram(...args),
|
||||
sendStickerTelegram: (...args: unknown[]) => sendStickerTelegram(...args),
|
||||
deleteMessageTelegram: (...args: unknown[]) => deleteMessageTelegram(...args),
|
||||
}));
|
||||
|
||||
@ -21,6 +26,7 @@ describe("handleTelegramAction", () => {
|
||||
beforeEach(() => {
|
||||
reactMessageTelegram.mockClear();
|
||||
sendMessageTelegram.mockClear();
|
||||
sendStickerTelegram.mockClear();
|
||||
deleteMessageTelegram.mockClear();
|
||||
process.env.TELEGRAM_BOT_TOKEN = "tok";
|
||||
});
|
||||
@ -96,6 +102,40 @@ describe("handleTelegramAction", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects sticker actions when disabled by default", async () => {
|
||||
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
|
||||
await expect(
|
||||
handleTelegramAction(
|
||||
{
|
||||
action: "sendSticker",
|
||||
to: "123",
|
||||
fileId: "sticker",
|
||||
},
|
||||
cfg,
|
||||
),
|
||||
).rejects.toThrow(/sticker actions are disabled/i);
|
||||
expect(sendStickerTelegram).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends stickers when enabled", async () => {
|
||||
const cfg = {
|
||||
channels: { telegram: { botToken: "tok", actions: { sticker: true } } },
|
||||
} as ClawdbotConfig;
|
||||
await handleTelegramAction(
|
||||
{
|
||||
action: "sendSticker",
|
||||
to: "123",
|
||||
fileId: "sticker",
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
expect(sendStickerTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"sticker",
|
||||
expect.objectContaining({ token: "tok" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes reactions when remove flag set", async () => {
|
||||
const cfg = {
|
||||
channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } },
|
||||
|
||||
@ -6,7 +6,9 @@ import {
|
||||
editMessageTelegram,
|
||||
reactMessageTelegram,
|
||||
sendMessageTelegram,
|
||||
sendStickerTelegram,
|
||||
} from "../../telegram/send.js";
|
||||
import { getCacheStats, searchStickers } from "../../telegram/sticker-cache.js";
|
||||
import { resolveTelegramToken } from "../../telegram/token.js";
|
||||
import {
|
||||
resolveTelegramInlineButtonsScope,
|
||||
@ -255,5 +257,64 @@ export async function handleTelegramAction(
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "sendSticker") {
|
||||
if (!isActionEnabled("sticker", false)) {
|
||||
throw new Error(
|
||||
"Telegram sticker actions are disabled. Set channels.telegram.actions.sticker to true.",
|
||||
);
|
||||
}
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const fileId = readStringParam(params, "fileId", { required: true });
|
||||
const replyToMessageId = readNumberParam(params, "replyToMessageId", {
|
||||
integer: true,
|
||||
});
|
||||
const messageThreadId = readNumberParam(params, "messageThreadId", {
|
||||
integer: true,
|
||||
});
|
||||
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||
);
|
||||
}
|
||||
const result = await sendStickerTelegram(to, fileId, {
|
||||
token,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId: replyToMessageId ?? undefined,
|
||||
messageThreadId: messageThreadId ?? undefined,
|
||||
});
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
messageId: result.messageId,
|
||||
chatId: result.chatId,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "searchSticker") {
|
||||
if (!isActionEnabled("sticker", false)) {
|
||||
throw new Error(
|
||||
"Telegram sticker actions are disabled. Set channels.telegram.actions.sticker to true.",
|
||||
);
|
||||
}
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const limit = readNumberParam(params, "limit", { integer: true }) ?? 5;
|
||||
const results = searchStickers(query, limit);
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
count: results.length,
|
||||
stickers: results.map((s) => ({
|
||||
fileId: s.fileId,
|
||||
emoji: s.emoji,
|
||||
description: s.description,
|
||||
setName: s.setName,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "stickerCacheStats") {
|
||||
const stats = getCacheStats();
|
||||
return jsonResult({ ok: true, ...stats });
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported Telegram action: ${action}`);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 = {
|
||||
|
||||
13
src/auto-reply/reply/commands-info.test.ts
Normal file
13
src/auto-reply/reply/commands-info.test.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCommandsPaginationKeyboard } from "./commands-info.js";
|
||||
|
||||
describe("buildCommandsPaginationKeyboard", () => {
|
||||
it("adds agent id to callback data when provided", () => {
|
||||
const keyboard = buildCommandsPaginationKeyboard(2, 3, "agent-main");
|
||||
expect(keyboard[0]).toEqual([
|
||||
{ text: "◀ Prev", callback_data: "commands_page_1:agent-main" },
|
||||
{ text: "2/3", callback_data: "commands_page_noop:agent-main" },
|
||||
{ text: "Next ▶", callback_data: "commands_page_3:agent-main" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,10 @@
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { 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 =
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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). */
|
||||
|
||||
@ -10,6 +10,13 @@ vi.mock("../../../agents/tools/telegram-actions.js", () => ({
|
||||
}));
|
||||
|
||||
describe("telegramMessageActions", () => {
|
||||
it("excludes sticker actions when not enabled", () => {
|
||||
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
|
||||
const actions = telegramMessageActions.listActions({ cfg });
|
||||
expect(actions).not.toContain("sticker");
|
||||
expect(actions).not.toContain("sticker-search");
|
||||
});
|
||||
|
||||
it("allows media-only sends and passes asVoice", async () => {
|
||||
handleTelegramAction.mockClear();
|
||||
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import {
|
||||
createActionGate,
|
||||
readNumberParam,
|
||||
readStringArrayParam,
|
||||
readStringOrNumberParam,
|
||||
readStringParam,
|
||||
} from "../../../agents/tools/common.js";
|
||||
@ -45,6 +46,10 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
if (gate("reactions")) actions.add("react");
|
||||
if (gate("deleteMessage")) actions.add("delete");
|
||||
if (gate("editMessage")) actions.add("edit");
|
||||
if (gate("sticker", false)) {
|
||||
actions.add("sticker");
|
||||
actions.add("sticker-search");
|
||||
}
|
||||
return Array.from(actions);
|
||||
},
|
||||
supportsButtons: ({ cfg }) => {
|
||||
@ -141,6 +146,41 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "sticker") {
|
||||
const to =
|
||||
readStringParam(params, "to") ?? readStringParam(params, "target", { required: true });
|
||||
// Accept stickerId (array from shared schema) and use first element as fileId
|
||||
const stickerIds = readStringArrayParam(params, "stickerId");
|
||||
const fileId = stickerIds?.[0] ?? readStringParam(params, "fileId", { required: true });
|
||||
const replyToMessageId = readNumberParam(params, "replyTo", { integer: true });
|
||||
const messageThreadId = readNumberParam(params, "threadId", { integer: true });
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "sendSticker",
|
||||
to,
|
||||
fileId,
|
||||
replyToMessageId: replyToMessageId ?? undefined,
|
||||
messageThreadId: messageThreadId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "sticker-search") {
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
return await handleTelegramAction(
|
||||
{
|
||||
action: "searchSticker",
|
||||
query,
|
||||
limit: limit ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
||||
},
|
||||
};
|
||||
|
||||
@ -25,6 +25,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
|
||||
"thread-reply",
|
||||
"search",
|
||||
"sticker",
|
||||
"sticker-search",
|
||||
"member-info",
|
||||
"role-info",
|
||||
"emoji-list",
|
||||
|
||||
@ -16,6 +16,8 @@ export type TelegramActionConfig = {
|
||||
sendMessage?: boolean;
|
||||
deleteMessage?: boolean;
|
||||
editMessage?: boolean;
|
||||
/** Enable sticker actions (send and search). */
|
||||
sticker?: boolean;
|
||||
};
|
||||
|
||||
export type TelegramNetworkConfig = {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -30,6 +30,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
|
||||
"thread-reply": "to",
|
||||
search: "none",
|
||||
sticker: "to",
|
||||
"sticker-search": "none",
|
||||
"member-info": "none",
|
||||
"role-info": "none",
|
||||
"emoji-list": "none",
|
||||
|
||||
@ -412,6 +412,39 @@ async function resolveAutoEntries(params: {
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function resolveAutoImageModel(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentDir?: string;
|
||||
activeModel?: ActiveMediaModel;
|
||||
}): Promise<ActiveMediaModel | null> {
|
||||
const providerRegistry = buildProviderRegistry();
|
||||
const toActive = (entry: MediaUnderstandingModelConfig | null): ActiveMediaModel | null => {
|
||||
if (!entry || entry.type === "cli") return null;
|
||||
const provider = entry.provider;
|
||||
if (!provider) return null;
|
||||
const model = entry.model ?? DEFAULT_IMAGE_MODELS[provider];
|
||||
if (!model) return null;
|
||||
return { provider, model };
|
||||
};
|
||||
const activeEntry = await resolveActiveModelEntry({
|
||||
cfg: params.cfg,
|
||||
agentDir: params.agentDir,
|
||||
providerRegistry,
|
||||
capability: "image",
|
||||
activeModel: params.activeModel,
|
||||
});
|
||||
const resolvedActive = toActive(activeEntry);
|
||||
if (resolvedActive) return resolvedActive;
|
||||
const keyEntry = await resolveKeyEntry({
|
||||
cfg: params.cfg,
|
||||
agentDir: params.agentDir,
|
||||
providerRegistry,
|
||||
capability: "image",
|
||||
activeModel: params.activeModel,
|
||||
});
|
||||
return toActive(keyEntry);
|
||||
}
|
||||
|
||||
async function resolveActiveModelEntry(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentDir?: string;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -21,7 +21,8 @@ import { loadWebMedia } from "../../web/media.js";
|
||||
import { buildInlineKeyboard } from "../send.js";
|
||||
import { resolveTelegramVoiceSend } from "../voice.js";
|
||||
import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js";
|
||||
import type { TelegramContext } from "./types.js";
|
||||
import type { StickerMetadata, TelegramContext } from "./types.js";
|
||||
import { cacheSticker, getCachedSticker } from "../sticker-cache.js";
|
||||
|
||||
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
|
||||
const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
|
||||
@ -261,8 +262,91 @@ export async function resolveMedia(
|
||||
maxBytes: number,
|
||||
token: string,
|
||||
proxyFetch?: typeof fetch,
|
||||
): Promise<{ path: string; contentType?: string; placeholder: string } | null> {
|
||||
): Promise<{
|
||||
path: string;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
stickerMetadata?: StickerMetadata;
|
||||
} | null> {
|
||||
const msg = ctx.message;
|
||||
|
||||
// Handle stickers separately - only static stickers (WEBP) are supported
|
||||
if (msg.sticker) {
|
||||
const sticker = msg.sticker;
|
||||
// Skip animated (TGS) and video (WEBM) stickers - only static WEBP supported
|
||||
if (sticker.is_animated || sticker.is_video) {
|
||||
logVerbose("telegram: skipping animated/video sticker (only static stickers supported)");
|
||||
return null;
|
||||
}
|
||||
if (!sticker.file_id) return null;
|
||||
|
||||
try {
|
||||
const file = await ctx.getFile();
|
||||
if (!file.file_path) {
|
||||
logVerbose("telegram: getFile returned no file_path for sticker");
|
||||
return null;
|
||||
}
|
||||
const fetchImpl = proxyFetch ?? globalThis.fetch;
|
||||
if (!fetchImpl) {
|
||||
logVerbose("telegram: fetch not available for sticker download");
|
||||
return null;
|
||||
}
|
||||
const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
|
||||
const fetched = await fetchRemoteMedia({
|
||||
url,
|
||||
fetchImpl,
|
||||
filePathHint: file.file_path,
|
||||
});
|
||||
const saved = await saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes);
|
||||
|
||||
// Check sticker cache for existing description
|
||||
const cached = sticker.file_unique_id ? getCachedSticker(sticker.file_unique_id) : null;
|
||||
if (cached) {
|
||||
logVerbose(`telegram: sticker cache hit for ${sticker.file_unique_id}`);
|
||||
const fileId = sticker.file_id ?? cached.fileId;
|
||||
const emoji = sticker.emoji ?? cached.emoji;
|
||||
const setName = sticker.set_name ?? cached.setName;
|
||||
if (fileId !== cached.fileId || emoji !== cached.emoji || setName !== cached.setName) {
|
||||
// Refresh cached sticker metadata on hits so sends/searches use latest file_id.
|
||||
cacheSticker({
|
||||
...cached,
|
||||
fileId,
|
||||
emoji,
|
||||
setName,
|
||||
});
|
||||
}
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "<media:sticker>",
|
||||
stickerMetadata: {
|
||||
emoji,
|
||||
setName,
|
||||
fileId,
|
||||
fileUniqueId: sticker.file_unique_id,
|
||||
cachedDescription: cached.description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Cache miss - return metadata for vision processing
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "<media:sticker>",
|
||||
stickerMetadata: {
|
||||
emoji: sticker.emoji ?? undefined,
|
||||
setName: sticker.set_name ?? undefined,
|
||||
fileId: sticker.file_id,
|
||||
fileUniqueId: sticker.file_unique_id,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
logVerbose(`telegram: failed to process sticker: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const m =
|
||||
msg.photo?.[msg.photo.length - 1] ?? msg.video ?? msg.document ?? msg.audio ?? msg.voice;
|
||||
if (!m?.file_id) return null;
|
||||
|
||||
@ -67,3 +67,17 @@ export interface TelegramVenue {
|
||||
google_place_id?: string;
|
||||
google_place_type?: string;
|
||||
}
|
||||
|
||||
/** Telegram sticker metadata for context enrichment. */
|
||||
export interface StickerMetadata {
|
||||
/** Emoji associated with the sticker. */
|
||||
emoji?: string;
|
||||
/** Name of the sticker set the sticker belongs to. */
|
||||
setName?: string;
|
||||
/** Telegram file_id for sending the sticker back. */
|
||||
fileId?: string;
|
||||
/** Stable file_unique_id for cache deduplication. */
|
||||
fileUniqueId?: string;
|
||||
/** Cached description from previous vision processing (skip re-processing if present). */
|
||||
cachedDescription?: string;
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ const { botApi, botCtorSpy } = vi.hoisted(() => ({
|
||||
botApi: {
|
||||
sendMessage: vi.fn(),
|
||||
setMessageReaction: vi.fn(),
|
||||
sendSticker: vi.fn(),
|
||||
},
|
||||
botCtorSpy: vi.fn(),
|
||||
}));
|
||||
@ -43,7 +44,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { buildInlineKeyboard, sendMessageTelegram } from "./send.js";
|
||||
import { buildInlineKeyboard, sendMessageTelegram, sendStickerTelegram } from "./send.js";
|
||||
|
||||
describe("buildInlineKeyboard", () => {
|
||||
it("returns undefined for empty input", () => {
|
||||
@ -566,3 +567,183 @@ describe("sendMessageTelegram", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendStickerTelegram", () => {
|
||||
beforeEach(() => {
|
||||
loadConfig.mockReturnValue({});
|
||||
botApi.sendSticker.mockReset();
|
||||
botCtorSpy.mockReset();
|
||||
});
|
||||
|
||||
it("sends a sticker by file_id", async () => {
|
||||
const chatId = "123";
|
||||
const fileId = "CAACAgIAAxkBAAI...sticker_file_id";
|
||||
const sendSticker = vi.fn().mockResolvedValue({
|
||||
message_id: 100,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendSticker } as unknown as {
|
||||
sendSticker: typeof sendSticker;
|
||||
};
|
||||
|
||||
const res = await sendStickerTelegram(chatId, fileId, {
|
||||
token: "tok",
|
||||
api,
|
||||
});
|
||||
|
||||
expect(sendSticker).toHaveBeenCalledWith(chatId, fileId, undefined);
|
||||
expect(res.messageId).toBe("100");
|
||||
expect(res.chatId).toBe(chatId);
|
||||
});
|
||||
|
||||
it("throws error when fileId is empty", async () => {
|
||||
await expect(sendStickerTelegram("123", "", { token: "tok" })).rejects.toThrow(
|
||||
/file_id is required/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws error when fileId is whitespace only", async () => {
|
||||
await expect(sendStickerTelegram("123", " ", { token: "tok" })).rejects.toThrow(
|
||||
/file_id is required/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("includes message_thread_id for forum topic messages", async () => {
|
||||
const chatId = "-1001234567890";
|
||||
const fileId = "CAACAgIAAxkBAAI...sticker_file_id";
|
||||
const sendSticker = vi.fn().mockResolvedValue({
|
||||
message_id: 101,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendSticker } as unknown as {
|
||||
sendSticker: typeof sendSticker;
|
||||
};
|
||||
|
||||
await sendStickerTelegram(chatId, fileId, {
|
||||
token: "tok",
|
||||
api,
|
||||
messageThreadId: 271,
|
||||
});
|
||||
|
||||
expect(sendSticker).toHaveBeenCalledWith(chatId, fileId, {
|
||||
message_thread_id: 271,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes reply_to_message_id for threaded replies", async () => {
|
||||
const chatId = "123";
|
||||
const fileId = "CAACAgIAAxkBAAI...sticker_file_id";
|
||||
const sendSticker = vi.fn().mockResolvedValue({
|
||||
message_id: 102,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendSticker } as unknown as {
|
||||
sendSticker: typeof sendSticker;
|
||||
};
|
||||
|
||||
await sendStickerTelegram(chatId, fileId, {
|
||||
token: "tok",
|
||||
api,
|
||||
replyToMessageId: 500,
|
||||
});
|
||||
|
||||
expect(sendSticker).toHaveBeenCalledWith(chatId, fileId, {
|
||||
reply_to_message_id: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes both thread and reply params for forum topic replies", async () => {
|
||||
const chatId = "-1001234567890";
|
||||
const fileId = "CAACAgIAAxkBAAI...sticker_file_id";
|
||||
const sendSticker = vi.fn().mockResolvedValue({
|
||||
message_id: 103,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendSticker } as unknown as {
|
||||
sendSticker: typeof sendSticker;
|
||||
};
|
||||
|
||||
await sendStickerTelegram(chatId, fileId, {
|
||||
token: "tok",
|
||||
api,
|
||||
messageThreadId: 271,
|
||||
replyToMessageId: 500,
|
||||
});
|
||||
|
||||
expect(sendSticker).toHaveBeenCalledWith(chatId, fileId, {
|
||||
message_thread_id: 271,
|
||||
reply_to_message_id: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes chat ids with internal prefixes", async () => {
|
||||
const sendSticker = vi.fn().mockResolvedValue({
|
||||
message_id: 104,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const api = { sendSticker } as unknown as {
|
||||
sendSticker: typeof sendSticker;
|
||||
};
|
||||
|
||||
await sendStickerTelegram("telegram:123", "fileId123", {
|
||||
token: "tok",
|
||||
api,
|
||||
});
|
||||
|
||||
expect(sendSticker).toHaveBeenCalledWith("123", "fileId123", undefined);
|
||||
});
|
||||
|
||||
it("parses message_thread_id from recipient string (telegram:group:...:topic:...)", async () => {
|
||||
const chatId = "-1001234567890";
|
||||
const sendSticker = vi.fn().mockResolvedValue({
|
||||
message_id: 105,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendSticker } as unknown as {
|
||||
sendSticker: typeof sendSticker;
|
||||
};
|
||||
|
||||
await sendStickerTelegram(`telegram:group:${chatId}:topic:271`, "fileId123", {
|
||||
token: "tok",
|
||||
api,
|
||||
});
|
||||
|
||||
expect(sendSticker).toHaveBeenCalledWith(chatId, "fileId123", {
|
||||
message_thread_id: 271,
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps chat-not-found with actionable context", async () => {
|
||||
const chatId = "123";
|
||||
const err = new Error("400: Bad Request: chat not found");
|
||||
const sendSticker = vi.fn().mockRejectedValue(err);
|
||||
const api = { sendSticker } as unknown as {
|
||||
sendSticker: typeof sendSticker;
|
||||
};
|
||||
|
||||
await expect(sendStickerTelegram(chatId, "fileId123", { token: "tok", api })).rejects.toThrow(
|
||||
/chat not found/i,
|
||||
);
|
||||
await expect(sendStickerTelegram(chatId, "fileId123", { token: "tok", api })).rejects.toThrow(
|
||||
/chat_id=123/,
|
||||
);
|
||||
});
|
||||
|
||||
it("trims whitespace from fileId", async () => {
|
||||
const chatId = "123";
|
||||
const sendSticker = vi.fn().mockResolvedValue({
|
||||
message_id: 106,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendSticker } as unknown as {
|
||||
sendSticker: typeof sendSticker;
|
||||
};
|
||||
|
||||
await sendStickerTelegram(chatId, " fileId123 ", {
|
||||
token: "tok",
|
||||
api,
|
||||
});
|
||||
|
||||
expect(sendSticker).toHaveBeenCalledWith(chatId, "fileId123", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@ -619,3 +619,96 @@ function inferFilename(kind: ReturnType<typeof mediaKindFromMime>) {
|
||||
return "file.bin";
|
||||
}
|
||||
}
|
||||
|
||||
type TelegramStickerOpts = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
verbose?: boolean;
|
||||
api?: Bot["api"];
|
||||
retry?: RetryConfig;
|
||||
/** Message ID to reply to (for threading) */
|
||||
replyToMessageId?: number;
|
||||
/** Forum topic thread ID (for forum supergroups) */
|
||||
messageThreadId?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a sticker to a Telegram chat by file_id.
|
||||
* @param to - Chat ID or username (e.g., "123456789" or "@username")
|
||||
* @param fileId - Telegram file_id of the sticker to send
|
||||
* @param opts - Optional configuration
|
||||
*/
|
||||
export async function sendStickerTelegram(
|
||||
to: string,
|
||||
fileId: string,
|
||||
opts: TelegramStickerOpts = {},
|
||||
): Promise<TelegramSendResult> {
|
||||
if (!fileId?.trim()) {
|
||||
throw new Error("Telegram sticker file_id is required");
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const account = resolveTelegramAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = resolveToken(opts.token, account);
|
||||
const target = parseTelegramTarget(to);
|
||||
const chatId = normalizeChatId(target.chatId);
|
||||
const client = resolveTelegramClientOptions(account);
|
||||
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
|
||||
|
||||
const messageThreadId =
|
||||
opts.messageThreadId != null ? opts.messageThreadId : target.messageThreadId;
|
||||
const threadIdParams = buildTelegramThreadParams(messageThreadId);
|
||||
const threadParams: Record<string, number> = threadIdParams ? { ...threadIdParams } : {};
|
||||
if (opts.replyToMessageId != null) {
|
||||
threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId);
|
||||
}
|
||||
const hasThreadParams = Object.keys(threadParams).length > 0;
|
||||
|
||||
const request = createTelegramRetryRunner({
|
||||
retry: opts.retry,
|
||||
configRetry: account.config.retry,
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
const logHttpError = createTelegramHttpLogger(cfg);
|
||||
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
|
||||
request(fn, label).catch((err) => {
|
||||
logHttpError(label ?? "request", err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
const wrapChatNotFound = (err: unknown) => {
|
||||
if (!/400: Bad Request: chat not found/i.test(formatErrorMessage(err))) return err;
|
||||
return new Error(
|
||||
[
|
||||
`Telegram send failed: chat not found (chat_id=${chatId}).`,
|
||||
"Likely: bot not started in DM, bot removed from group/channel, group migrated (new -100… id), or wrong bot token.",
|
||||
`Input was: ${JSON.stringify(to)}.`,
|
||||
].join(" "),
|
||||
);
|
||||
};
|
||||
|
||||
const stickerParams = hasThreadParams ? threadParams : undefined;
|
||||
|
||||
const result = await requestWithDiag(
|
||||
() => api.sendSticker(chatId, fileId.trim(), stickerParams),
|
||||
"sticker",
|
||||
).catch((err) => {
|
||||
throw wrapChatNotFound(err);
|
||||
});
|
||||
|
||||
const messageId = String(result?.message_id ?? "unknown");
|
||||
const resolvedChatId = String(result?.chat?.id ?? chatId);
|
||||
if (result?.message_id) {
|
||||
recordSentMessage(chatId, result.message_id);
|
||||
}
|
||||
recordChannelActivity({
|
||||
channel: "telegram",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
return { messageId, chatId: resolvedChatId };
|
||||
}
|
||||
|
||||
257
src/telegram/sticker-cache.test.ts
Normal file
257
src/telegram/sticker-cache.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
260
src/telegram/sticker-cache.ts
Normal file
260
src/telegram/sticker-cache.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user