Merge branch 'clawdbot:main' into fix/discord-username-resolve

This commit is contained in:
Manoj Naik 2026-01-27 14:52:06 +05:30 committed by GitHub
commit 98d0929bbc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 753 additions and 180 deletions

View File

@ -2,10 +2,12 @@
Docs: https://docs.clawd.bot
## 2026.1.25
## 2026.1.26
Status: unreleased.
### Changes
- Rebrand: rename the npm package/CLI to `moltbot`, add a `clawdbot` compatibility shim, and move extensions to the `@moltbot/*` scope.
- Commands: group /help and /commands output with Telegram paging. (#2504) Thanks @hougangdev.
- macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk).
- Tools: add per-sender group tool policies and fix precedence. (#1757) Thanks @adam91holt.
- Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47.
@ -54,6 +56,7 @@ Status: unreleased.
- Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma.
- Telegram: support editing sent messages via message(action="edit"). (#2394) Thanks @marcelomar21.
- Telegram: add sticker receive/send with vision caching. (#2629) Thanks @longjos.
- Telegram: send sticker pixels to vision models. (#2650)
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.

View File

@ -21,8 +21,8 @@ android {
applicationId = "com.clawdbot.android"
minSdk = 31
targetSdk = 36
versionCode = 202601250
versionName = "2026.1.25"
versionCode = 202601260
versionName = "2026.1.26"
}
buildTypes {

View File

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

View File

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

View File

@ -81,8 +81,8 @@ targets:
properties:
CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.1.25"
CFBundleVersion: "20260125"
CFBundleShortVersionString: "2026.1.26"
CFBundleVersion: "20260126"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: ClawdbotTests
CFBundleShortVersionString: "2026.1.25"
CFBundleVersion: "20260125"
CFBundleShortVersionString: "2026.1.26"
CFBundleVersion: "20260126"

View File

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

View File

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

View File

@ -30,17 +30,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.25 \
APP_VERSION=2026.1.26 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-app.sh
# Zip for distribution (includes resource forks for Sparkle delta support)
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.25.zip
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.26.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.25.dmg
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.26.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.25.dmg
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.25 \
APP_VERSION=2026.1.26 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.25.dSYM.zip
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.26.dSYM.zip
```
## Appcast entry
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.25.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.26.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
## Publish & verify
- Upload `Clawdbot-2026.1.25.zip` (and `Clawdbot-2026.1.25.dSYM.zip`) to the GitHub release for tag `v2026.1.25`.
- Upload `Clawdbot-2026.1.26.zip` (and `Clawdbot-2026.1.26.dSYM.zip`) to the GitHub release for tag `v2026.1.26`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.

View File

@ -17,7 +17,7 @@ When the operator says “release”, immediately do this preflight (no extra qu
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
1) **Version & metadata**
- [ ] Bump `package.json` version (e.g., `2026.1.25`).
- [ ] Bump `package.json` version (e.g., `2026.1.26`).
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
- [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/provider-web.ts).
- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js) for `clawdbot`.

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/bluebubbles",
"version": "2026.1.25",
"name": "@moltbot/bluebubbles",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot BlueBubbles channel plugin",
"clawdbot": {
@ -25,7 +25,7 @@
"order": 75
},
"install": {
"npmSpec": "@clawdbot/bluebubbles",
"npmSpec": "@moltbot/bluebubbles",
"localPath": "extensions/bluebubbles",
"defaultChoice": "npm"
}

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/copilot-proxy",
"version": "2026.1.25",
"name": "@moltbot/copilot-proxy",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot Copilot Proxy provider plugin",
"clawdbot": {

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/diagnostics-otel",
"version": "2026.1.25",
"name": "@moltbot/diagnostics-otel",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot diagnostics OpenTelemetry exporter",
"clawdbot": {

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/discord",
"version": "2026.1.25",
"name": "@moltbot/discord",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot Discord channel plugin",
"clawdbot": {

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-antigravity-auth",
"version": "2026.1.25",
"name": "@moltbot/google-antigravity-auth",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot Google Antigravity OAuth provider plugin",
"clawdbot": {

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-gemini-cli-auth",
"version": "2026.1.25",
"name": "@moltbot/google-gemini-cli-auth",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot Gemini CLI OAuth provider plugin",
"clawdbot": {

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/googlechat",
"version": "2026.1.25",
"name": "@moltbot/googlechat",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot Google Chat channel plugin",
"clawdbot": {
@ -22,7 +22,7 @@
"order": 55
},
"install": {
"npmSpec": "@clawdbot/googlechat",
"npmSpec": "@moltbot/googlechat",
"localPath": "extensions/googlechat",
"defaultChoice": "npm"
}
@ -34,6 +34,6 @@
"clawdbot": "workspace:*"
},
"peerDependencies": {
"clawdbot": ">=2026.1.25"
"clawdbot": ">=2026.1.26"
}
}

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/imessage",
"version": "2026.1.25",
"name": "@moltbot/imessage",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot iMessage channel plugin",
"clawdbot": {

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/line",
"version": "2026.1.25",
"name": "@moltbot/line",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot LINE channel plugin",
"clawdbot": {
@ -18,7 +18,7 @@
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@clawdbot/line",
"npmSpec": "@moltbot/line",
"localPath": "extensions/line",
"defaultChoice": "npm"
}

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/llm-task",
"version": "2026.1.25",
"name": "@moltbot/llm-task",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot JSON-only LLM task plugin",
"clawdbot": {

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/lobster",
"version": "2026.1.25",
"name": "@moltbot/lobster",
"version": "2026.1.26",
"type": "module",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"clawdbot": {

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/matrix",
"version": "2026.1.25",
"name": "@moltbot/matrix",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot Matrix channel plugin",
"clawdbot": {
@ -18,7 +18,7 @@
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@clawdbot/matrix",
"npmSpec": "@moltbot/matrix",
"localPath": "extensions/matrix",
"defaultChoice": "npm"
}

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/mattermost",
"version": "2026.1.25",
"name": "@moltbot/mattermost",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot Mattermost channel plugin",
"clawdbot": {
@ -17,7 +17,7 @@
"order": 65
},
"install": {
"npmSpec": "@clawdbot/mattermost",
"npmSpec": "@moltbot/mattermost",
"localPath": "extensions/mattermost",
"defaultChoice": "npm"
}

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-core",
"version": "2026.1.25",
"name": "@moltbot/memory-core",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot core memory search plugin",
"clawdbot": {
@ -9,6 +9,6 @@
]
},
"peerDependencies": {
"clawdbot": ">=2026.1.24-3"
"clawdbot": ">=2026.1.26"
}
}

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-lancedb",
"version": "2026.1.25",
"name": "@moltbot/memory-lancedb",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/msteams",
"version": "2026.1.25",
"name": "@moltbot/msteams",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot Microsoft Teams channel plugin",
"clawdbot": {
@ -20,7 +20,7 @@
"order": 60
},
"install": {
"npmSpec": "@clawdbot/msteams",
"npmSpec": "@moltbot/msteams",
"localPath": "extensions/msteams",
"defaultChoice": "npm"
}

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/nextcloud-talk",
"version": "2026.1.25",
"name": "@moltbot/nextcloud-talk",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot Nextcloud Talk channel plugin",
"clawdbot": {
@ -22,7 +22,7 @@
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@clawdbot/nextcloud-talk",
"npmSpec": "@moltbot/nextcloud-talk",
"localPath": "extensions/nextcloud-talk",
"defaultChoice": "npm"
}

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/nostr",
"version": "2026.1.25",
"name": "@moltbot/nostr",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
"clawdbot": {
@ -18,7 +18,7 @@
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@clawdbot/nostr",
"npmSpec": "@moltbot/nostr",
"localPath": "extensions/nostr",
"defaultChoice": "npm"
}

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/open-prose",
"version": "2026.1.25",
"name": "@moltbot/open-prose",
"version": "2026.1.26",
"type": "module",
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"clawdbot": {

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/signal",
"version": "2026.1.25",
"name": "@moltbot/signal",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot Signal channel plugin",
"clawdbot": {

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/slack",
"version": "2026.1.25",
"name": "@moltbot/slack",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot Slack channel plugin",
"clawdbot": {

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/telegram",
"version": "2026.1.25",
"name": "@moltbot/telegram",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot Telegram channel plugin",
"clawdbot": {

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/tlon",
"version": "2026.1.25",
"name": "@moltbot/tlon",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot Tlon/Urbit channel plugin",
"clawdbot": {
@ -18,7 +18,7 @@
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@clawdbot/tlon",
"npmSpec": "@moltbot/tlon",
"localPath": "extensions/tlon",
"defaultChoice": "npm"
}

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/twitch",
"version": "2026.1.23",
"name": "@moltbot/twitch",
"version": "2026.1.26",
"description": "Clawdbot Twitch channel plugin",
"type": "module",
"dependencies": {

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/voice-call",
"version": "2026.1.25",
"name": "@moltbot/voice-call",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot voice-call plugin",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/whatsapp",
"version": "2026.1.25",
"name": "@moltbot/whatsapp",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot WhatsApp channel plugin",
"clawdbot": {

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalo",
"version": "2026.1.25",
"name": "@moltbot/zalo",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot Zalo channel plugin",
"clawdbot": {
@ -21,7 +21,7 @@
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@clawdbot/zalo",
"npmSpec": "@moltbot/zalo",
"localPath": "extensions/zalo",
"defaultChoice": "npm"
}

View File

@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalouser",
"version": "2026.1.25",
"name": "@moltbot/zalouser",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
"dependencies": {
@ -25,7 +25,7 @@
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@clawdbot/zalouser",
"npmSpec": "@moltbot/zalouser",
"localPath": "extensions/zalouser",
"defaultChoice": "npm"
}

View File

@ -1,15 +1,17 @@
{
"name": "clawdbot",
"version": "2026.1.25",
"name": "moltbot",
"version": "2026.1.26",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
"exports": {
".": "./dist/index.js",
"./plugin-sdk": "./dist/plugin-sdk/index.js",
"./plugin-sdk/*": "./dist/plugin-sdk/*"
"./plugin-sdk/*": "./dist/plugin-sdk/*",
"./cli-entry": "./dist/entry.js"
},
"bin": {
"moltbot": "dist/entry.js",
"clawdbot": "dist/entry.js"
},
"files": [
@ -90,6 +92,7 @@
"ui:build": "node scripts/ui.js build",
"start": "node scripts/run-node.mjs",
"clawdbot": "node scripts/run-node.mjs",
"moltbot": "node scripts/run-node.mjs",
"gateway:watch": "node scripts/watch-node.mjs gateway --force",
"gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway",
"gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway --reset",

View File

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

View File

@ -2,7 +2,11 @@ import { listChannelDocks } from "../channels/dock.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { listThinkingLevels } from "./thinking.js";
import { COMMAND_ARG_FORMATTERS } from "./commands-args.js";
import type { ChatCommandDefinition, CommandScope } from "./commands-registry.types.js";
import type {
ChatCommandDefinition,
CommandCategory,
CommandScope,
} from "./commands-registry.types.js";
type DefineChatCommandInput = {
key: string;
@ -16,6 +20,7 @@ type DefineChatCommandInput = {
textAlias?: string;
textAliases?: string[];
scope?: CommandScope;
category?: CommandCategory;
};
function defineChatCommand(command: DefineChatCommandInput): ChatCommandDefinition {
@ -37,6 +42,7 @@ function defineChatCommand(command: DefineChatCommandInput): ChatCommandDefiniti
argsMenu: command.argsMenu,
textAliases: aliases,
scope,
category: command.category,
};
}
@ -48,6 +54,7 @@ function defineDockCommand(dock: ChannelDock): ChatCommandDefinition {
nativeName: `dock_${dock.id}`,
description: `Switch to ${dock.id} for replies.`,
textAliases: [`/dock-${dock.id}`, `/dock_${dock.id}`],
category: "docks",
});
}
@ -124,18 +131,21 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "help",
description: "Show available commands.",
textAlias: "/help",
category: "status",
}),
defineChatCommand({
key: "commands",
nativeName: "commands",
description: "List all slash commands.",
textAlias: "/commands",
category: "status",
}),
defineChatCommand({
key: "skill",
nativeName: "skill",
description: "Run a skill by name.",
textAlias: "/skill",
category: "tools",
args: [
{
name: "name",
@ -156,6 +166,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "status",
description: "Show current status.",
textAlias: "/status",
category: "status",
}),
defineChatCommand({
key: "allowlist",
@ -163,6 +174,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
textAlias: "/allowlist",
acceptsArgs: true,
scope: "text",
category: "management",
}),
defineChatCommand({
key: "approve",
@ -170,6 +182,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
description: "Approve or deny exec requests.",
textAlias: "/approve",
acceptsArgs: true,
category: "management",
}),
defineChatCommand({
key: "context",
@ -177,12 +190,14 @@ function buildChatCommands(): ChatCommandDefinition[] {
description: "Explain how context is built and used.",
textAlias: "/context",
acceptsArgs: true,
category: "status",
}),
defineChatCommand({
key: "tts",
nativeName: "tts",
description: "Control text-to-speech (TTS).",
textAlias: "/tts",
category: "media",
args: [
{
name: "action",
@ -225,12 +240,14 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "whoami",
description: "Show your sender id.",
textAlias: "/whoami",
category: "status",
}),
defineChatCommand({
key: "subagents",
nativeName: "subagents",
description: "List/stop/log/info subagent runs for this session.",
textAlias: "/subagents",
category: "management",
args: [
{
name: "action",
@ -257,6 +274,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "config",
description: "Show or set config values.",
textAlias: "/config",
category: "management",
args: [
{
name: "action",
@ -284,6 +302,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "debug",
description: "Set runtime debug overrides.",
textAlias: "/debug",
category: "management",
args: [
{
name: "action",
@ -311,6 +330,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "usage",
description: "Usage footer or cost summary.",
textAlias: "/usage",
category: "options",
args: [
{
name: "mode",
@ -326,18 +346,21 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "stop",
description: "Stop the current run.",
textAlias: "/stop",
category: "session",
}),
defineChatCommand({
key: "restart",
nativeName: "restart",
description: "Restart Clawdbot.",
textAlias: "/restart",
category: "tools",
}),
defineChatCommand({
key: "activation",
nativeName: "activation",
description: "Set group activation mode.",
textAlias: "/activation",
category: "management",
args: [
{
name: "mode",
@ -353,6 +376,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "send",
description: "Set send policy.",
textAlias: "/send",
category: "management",
args: [
{
name: "mode",
@ -369,6 +393,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
description: "Reset the current session.",
textAlias: "/reset",
acceptsArgs: true,
category: "session",
}),
defineChatCommand({
key: "new",
@ -376,12 +401,14 @@ function buildChatCommands(): ChatCommandDefinition[] {
description: "Start a new session.",
textAlias: "/new",
acceptsArgs: true,
category: "session",
}),
defineChatCommand({
key: "compact",
description: "Compact the session context.",
textAlias: "/compact",
scope: "text",
category: "session",
args: [
{
name: "instructions",
@ -396,6 +423,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "think",
description: "Set thinking level.",
textAlias: "/think",
category: "options",
args: [
{
name: "level",
@ -411,6 +439,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "verbose",
description: "Toggle verbose mode.",
textAlias: "/verbose",
category: "options",
args: [
{
name: "mode",
@ -426,6 +455,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "reasoning",
description: "Toggle reasoning visibility.",
textAlias: "/reasoning",
category: "options",
args: [
{
name: "mode",
@ -441,6 +471,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "elevated",
description: "Toggle elevated mode.",
textAlias: "/elevated",
category: "options",
args: [
{
name: "mode",
@ -456,6 +487,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "exec",
description: "Set exec defaults for this session.",
textAlias: "/exec",
category: "options",
args: [
{
name: "options",
@ -470,6 +502,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "model",
description: "Show or set the model.",
textAlias: "/model",
category: "options",
args: [
{
name: "model",
@ -485,12 +518,14 @@ function buildChatCommands(): ChatCommandDefinition[] {
textAlias: "/models",
argsParsing: "none",
acceptsArgs: true,
category: "options",
}),
defineChatCommand({
key: "queue",
nativeName: "queue",
description: "Adjust queue settings.",
textAlias: "/queue",
category: "options",
args: [
{
name: "mode",
@ -523,6 +558,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
description: "Run host shell commands (host-only).",
textAlias: "/bash",
scope: "text",
category: "tools",
args: [
{
name: "command",

View File

@ -2,6 +2,15 @@ import type { ClawdbotConfig } from "../config/types.js";
export type CommandScope = "text" | "native" | "both";
export type CommandCategory =
| "session"
| "options"
| "status"
| "management"
| "media"
| "tools"
| "docks";
export type CommandArgType = "string" | "number" | "boolean";
export type CommandArgChoiceContext = {
@ -51,6 +60,7 @@ export type ChatCommandDefinition = {
formatArgs?: (values: CommandArgValues) => string | undefined;
argsMenu?: CommandArgMenuSpec | "auto";
scope: CommandScope;
category?: CommandCategory;
};
export type NativeCommandSpec = {

View File

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

View File

@ -1,6 +1,10 @@
import { logVerbose } from "../../globals.js";
import { listSkillCommandsForWorkspace } from "../skill-commands.js";
import { buildCommandsMessage, buildHelpMessage } from "../status.js";
import { listSkillCommandsForAgents } from "../skill-commands.js";
import {
buildCommandsMessage,
buildCommandsMessagePaginated,
buildHelpMessage,
} from "../status.js";
import { buildStatusReply } from "./commands-status.js";
import { buildContextReply } from "./commands-context-report.js";
import type { CommandHandler } from "./commands-types.js";
@ -31,16 +35,78 @@ export const handleCommandsListCommand: CommandHandler = async (params, allowTex
}
const skillCommands =
params.skillCommands ??
listSkillCommandsForWorkspace({
workspaceDir: params.workspaceDir,
listSkillCommandsForAgents({
cfg: params.cfg,
agentIds: params.agentId ? [params.agentId] : undefined,
});
const surface = params.ctx.Surface;
if (surface === "telegram") {
const result = buildCommandsMessagePaginated(params.cfg, skillCommands, {
page: 1,
surface,
});
if (result.totalPages > 1) {
return {
shouldContinue: false,
reply: {
text: result.text,
channelData: {
telegram: {
buttons: buildCommandsPaginationKeyboard(
result.currentPage,
result.totalPages,
params.agentId,
),
},
},
},
};
}
return {
shouldContinue: false,
reply: { text: result.text },
};
}
return {
shouldContinue: false,
reply: { text: buildCommandsMessage(params.cfg, skillCommands) },
reply: { text: buildCommandsMessage(params.cfg, skillCommands, { surface }) },
};
};
export function buildCommandsPaginationKeyboard(
currentPage: number,
totalPages: number,
agentId?: string,
): Array<Array<{ text: string; callback_data: string }>> {
const buttons: Array<{ text: string; callback_data: string }> = [];
const suffix = agentId ? `:${agentId}` : "";
if (currentPage > 1) {
buttons.push({
text: "◀ Prev",
callback_data: `commands_page_${currentPage - 1}${suffix}`,
});
}
buttons.push({
text: `${currentPage}/${totalPages}`,
callback_data: `commands_page_noop${suffix}`,
});
if (currentPage < totalPages) {
buttons.push({
text: "Next ▶",
callback_data: `commands_page_${currentPage + 1}${suffix}`,
});
}
return [buttons];
}
export const handleStatusCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const statusRequested =

View File

@ -4,7 +4,20 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js";
import { buildCommandsMessage, buildHelpMessage, buildStatusMessage } from "./status.js";
import {
buildCommandsMessage,
buildCommandsMessagePaginated,
buildHelpMessage,
buildStatusMessage,
} from "./status.js";
const { listPluginCommands } = vi.hoisted(() => ({
listPluginCommands: vi.fn(() => []),
}));
vi.mock("../plugins/commands.js", () => ({
listPluginCommands,
}));
afterEach(() => {
vi.restoreAllMocks();
@ -400,10 +413,12 @@ describe("buildCommandsMessage", () => {
const text = buildCommandsMessage({
commands: { config: false, debug: false },
} as ClawdbotConfig);
expect(text).toContain(" Slash commands");
expect(text).toContain("Status");
expect(text).toContain("/commands - List all slash commands.");
expect(text).toContain("/skill - Run a skill by name.");
expect(text).toContain("/think (aliases: /thinking, /t) - Set thinking level.");
expect(text).toContain("/compact (text-only) - Compact the session context.");
expect(text).toContain("/think (/thinking, /t) - Set thinking level.");
expect(text).toContain("/compact [text] - Compact the session context.");
expect(text).not.toContain("/config");
expect(text).not.toContain("/debug");
});
@ -430,8 +445,39 @@ describe("buildHelpMessage", () => {
const text = buildHelpMessage({
commands: { config: false, debug: false },
} as ClawdbotConfig);
expect(text).toContain("Skills: /skill <name> [input]");
expect(text).toContain("Skills");
expect(text).toContain("/skill <name> [input]");
expect(text).not.toContain("/config");
expect(text).not.toContain("/debug");
});
});
describe("buildCommandsMessagePaginated", () => {
it("formats telegram output with pages", () => {
const result = buildCommandsMessagePaginated(
{
commands: { config: false, debug: false },
} as ClawdbotConfig,
undefined,
{ surface: "telegram", page: 1 },
);
expect(result.text).toContain(" Commands (1/");
expect(result.text).toContain("Session");
expect(result.text).toContain("/stop - Stop the current run.");
});
it("includes plugin commands in the paginated list", () => {
listPluginCommands.mockReturnValue([
{ name: "plugin_cmd", description: "Plugin command", pluginId: "demo-plugin" },
]);
const result = buildCommandsMessagePaginated(
{
commands: { config: false, debug: false },
} as ClawdbotConfig,
undefined,
{ surface: "telegram", page: 99 },
);
expect(result.text).toContain("Plugins");
expect(result.text).toContain("/plugin_cmd (demo-plugin) - Plugin command");
});
});

View File

@ -29,9 +29,14 @@ import {
resolveModelCostConfig,
} from "../utils/usage-format.js";
import { VERSION } from "../version.js";
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
import {
listChatCommands,
listChatCommandsForConfig,
type ChatCommandDefinition,
} from "./commands-registry.js";
import { listPluginCommands } from "../plugins/commands.js";
import type { SkillCommandSpec } from "../agents/skills.js";
import type { CommandCategory } from "./commands-registry.types.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
import type { MediaUnderstandingDecision } from "../media-understanding/types.js";
@ -427,61 +432,203 @@ export function buildStatusMessage(args: StatusArgs): string {
.join("\n");
}
const CATEGORY_LABELS: Record<CommandCategory, string> = {
session: "Session",
options: "Options",
status: "Status",
management: "Management",
media: "Media",
tools: "Tools",
docks: "Docks",
};
const CATEGORY_ORDER: CommandCategory[] = [
"session",
"options",
"status",
"management",
"media",
"tools",
"docks",
];
function groupCommandsByCategory(
commands: ChatCommandDefinition[],
): Map<CommandCategory, ChatCommandDefinition[]> {
const grouped = new Map<CommandCategory, ChatCommandDefinition[]>();
for (const category of CATEGORY_ORDER) {
grouped.set(category, []);
}
for (const command of commands) {
const category = command.category ?? "tools";
const list = grouped.get(category) ?? [];
list.push(command);
grouped.set(category, list);
}
return grouped;
}
export function buildHelpMessage(cfg?: ClawdbotConfig): string {
const options = [
"/think <level>",
"/verbose on|full|off",
"/reasoning on|off",
"/elevated on|off|ask|full",
"/model <id>",
"/usage off|tokens|full",
];
if (cfg?.commands?.config === true) options.push("/config show");
if (cfg?.commands?.debug === true) options.push("/debug show");
return [
" Help",
"Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)",
`Options: ${options.join(" | ")}`,
"Skills: /skill <name> [input]",
"More: /commands for all slash commands",
].join("\n");
const lines = [" Help", ""];
lines.push("Session");
lines.push(" /new | /reset | /compact [instructions] | /stop");
lines.push("");
const optionParts = ["/think <level>", "/model <id>", "/verbose on|off"];
if (cfg?.commands?.config === true) optionParts.push("/config");
if (cfg?.commands?.debug === true) optionParts.push("/debug");
lines.push("Options");
lines.push(` ${optionParts.join(" | ")}`);
lines.push("");
lines.push("Status");
lines.push(" /status | /whoami | /context");
lines.push("");
lines.push("Skills");
lines.push(" /skill <name> [input]");
lines.push("");
lines.push("More: /commands for full list");
return lines.join("\n");
}
const COMMANDS_PER_PAGE = 8;
export type CommandsMessageOptions = {
page?: number;
surface?: string;
};
export type CommandsMessageResult = {
text: string;
totalPages: number;
currentPage: number;
hasNext: boolean;
hasPrev: boolean;
};
function formatCommandEntry(command: ChatCommandDefinition): string {
const primary = command.nativeName
? `/${command.nativeName}`
: command.textAliases[0]?.trim() || `/${command.key}`;
const seen = new Set<string>();
const aliases = command.textAliases
.map((alias) => alias.trim())
.filter(Boolean)
.filter((alias) => alias.toLowerCase() !== primary.toLowerCase())
.filter((alias) => {
const key = alias.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const aliasLabel = aliases.length ? ` (${aliases.join(", ")})` : "";
const scopeLabel = command.scope === "text" ? " [text]" : "";
return `${primary}${aliasLabel}${scopeLabel} - ${command.description}`;
}
type CommandsListItem = {
label: string;
text: string;
};
function buildCommandItems(
commands: ChatCommandDefinition[],
pluginCommands: ReturnType<typeof listPluginCommands>,
): CommandsListItem[] {
const grouped = groupCommandsByCategory(commands);
const items: CommandsListItem[] = [];
for (const category of CATEGORY_ORDER) {
const categoryCommands = grouped.get(category) ?? [];
if (categoryCommands.length === 0) continue;
const label = CATEGORY_LABELS[category];
for (const command of categoryCommands) {
items.push({ label, text: formatCommandEntry(command) });
}
}
for (const command of pluginCommands) {
const pluginLabel = command.pluginId ? ` (${command.pluginId})` : "";
items.push({
label: "Plugins",
text: `/${command.name}${pluginLabel} - ${command.description}`,
});
}
return items;
}
function formatCommandList(items: CommandsListItem[]): string {
const lines: string[] = [];
let currentLabel: string | null = null;
for (const item of items) {
if (item.label !== currentLabel) {
if (lines.length > 0) lines.push("");
lines.push(item.label);
currentLabel = item.label;
}
lines.push(` ${item.text}`);
}
return lines.join("\n");
}
export function buildCommandsMessage(
cfg?: ClawdbotConfig,
skillCommands?: SkillCommandSpec[],
options?: CommandsMessageOptions,
): string {
const lines = [" Slash commands"];
const result = buildCommandsMessagePaginated(cfg, skillCommands, options);
return result.text;
}
export function buildCommandsMessagePaginated(
cfg?: ClawdbotConfig,
skillCommands?: SkillCommandSpec[],
options?: CommandsMessageOptions,
): CommandsMessageResult {
const page = Math.max(1, options?.page ?? 1);
const surface = options?.surface?.toLowerCase();
const isTelegram = surface === "telegram";
const commands = cfg
? listChatCommandsForConfig(cfg, { skillCommands })
: listChatCommands({ skillCommands });
for (const command of commands) {
const primary = command.nativeName
? `/${command.nativeName}`
: command.textAliases[0]?.trim() || `/${command.key}`;
const seen = new Set<string>();
const aliases = command.textAliases
.map((alias) => alias.trim())
.filter(Boolean)
.filter((alias) => alias.toLowerCase() !== primary.toLowerCase())
.filter((alias) => {
const key = alias.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const aliasLabel = aliases.length ? ` (aliases: ${aliases.join(", ")})` : "";
const scopeLabel = command.scope === "text" ? " (text-only)" : "";
lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`);
}
const pluginCommands = listPluginCommands();
if (pluginCommands.length > 0) {
lines.push("");
lines.push("Plugin commands:");
for (const command of pluginCommands) {
const pluginLabel = command.pluginId ? ` (plugin: ${command.pluginId})` : "";
lines.push(`/${command.name}${pluginLabel} - ${command.description}`);
}
const items = buildCommandItems(commands, pluginCommands);
if (!isTelegram) {
const lines = [" Slash commands", ""];
lines.push(formatCommandList(items));
return {
text: lines.join("\n").trim(),
totalPages: 1,
currentPage: 1,
hasNext: false,
hasPrev: false,
};
}
return lines.join("\n");
const totalCommands = items.length;
const totalPages = Math.max(1, Math.ceil(totalCommands / COMMANDS_PER_PAGE));
const currentPage = Math.min(page, totalPages);
const startIndex = (currentPage - 1) * COMMANDS_PER_PAGE;
const endIndex = startIndex + COMMANDS_PER_PAGE;
const pageItems = items.slice(startIndex, endIndex);
const lines = [` Commands (${currentPage}/${totalPages})`, ""];
lines.push(formatCommandList(pageItems));
return {
text: lines.join("\n").trim(),
totalPages,
currentPage,
hasNext: currentPage < totalPages,
hasPrev: currentPage > 1,
};
}

View File

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

View File

@ -1,6 +1,12 @@
import type { Bot } from "grammy";
import { resolveAckReaction } from "../agents/identity.js";
import {
findModelInCatalog,
loadModelCatalog,
modelSupportsVision,
} from "../agents/model-catalog.js";
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import { normalizeCommandBody } from "../auto-reply/commands-registry.js";
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js";
@ -104,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,
@ -316,7 +340,10 @@ export const buildTelegramMessageContext = async ({
// Check if sticker has a cached description - if so, use it instead of sending the image
const cachedStickerDescription = allMedia[0]?.stickerMetadata?.cachedDescription;
const stickerCacheHit = Boolean(cachedStickerDescription);
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;

View File

@ -1,5 +1,11 @@
// @ts-nocheck
import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js";
import {
findModelInCatalog,
loadModelCatalog,
modelSupportsVision,
} from "../agents/model-catalog.js";
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
import { resolveChunkMode } from "../auto-reply/chunk.js";
import { clearHistoryEntriesIfEnabled } from "../auto-reply/reply/history.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
@ -15,6 +21,18 @@ 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,
bot,
@ -133,14 +151,18 @@ export const dispatchTelegramMessage = async ({
// Handle uncached stickers: get a dedicated vision description before dispatch
// This ensures we cache a raw description rather than a conversational response
const sticker = ctxPayload.Sticker;
if (sticker?.fileUniqueId && !sticker.cachedDescription && ctxPayload.MediaPath) {
if (sticker?.fileUniqueId && ctxPayload.MediaPath) {
const agentDir = resolveAgentDir(cfg, route.agentId);
const description = await describeStickerImage({
imagePath: ctxPayload.MediaPath,
cfg,
agentDir,
agentId: 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]
@ -148,17 +170,19 @@ export const dispatchTelegramMessage = async ({
.join(" ");
const formattedDesc = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${description}`;
// Update context to use description instead of image
sticker.cachedDescription = description;
ctxPayload.Body = formattedDesc;
ctxPayload.BodyForAgent = formattedDesc;
// Clear media paths so native vision doesn't process the image again
ctxPayload.MediaPath = undefined;
ctxPayload.MediaType = undefined;
ctxPayload.MediaUrl = undefined;
ctxPayload.MediaPaths = undefined;
ctxPayload.MediaUrls = undefined;
ctxPayload.MediaTypes = undefined;
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({

View File

@ -93,6 +93,7 @@ const commandSpy = vi.fn();
const botCtorSpy = vi.fn();
const answerCallbackQuerySpy = vi.fn(async () => undefined);
const sendChatActionSpy = vi.fn();
const editMessageTextSpy = vi.fn(async () => ({ message_id: 88 }));
const setMessageReactionSpy = vi.fn(async () => undefined);
const setMyCommandsSpy = vi.fn(async () => undefined);
const sendMessageSpy = vi.fn(async () => ({ message_id: 77 }));
@ -102,6 +103,7 @@ type ApiStub = {
config: { use: (arg: unknown) => void };
answerCallbackQuery: typeof answerCallbackQuerySpy;
sendChatAction: typeof sendChatActionSpy;
editMessageText: typeof editMessageTextSpy;
setMessageReaction: typeof setMessageReactionSpy;
setMyCommands: typeof setMyCommandsSpy;
sendMessage: typeof sendMessageSpy;
@ -112,6 +114,7 @@ const apiStub: ApiStub = {
config: { use: useSpy },
answerCallbackQuery: answerCallbackQuerySpy,
sendChatAction: sendChatActionSpy,
editMessageText: editMessageTextSpy,
setMessageReaction: setMessageReactionSpy,
setMyCommands: setMyCommandsSpy,
sendMessage: sendMessageSpy,
@ -192,6 +195,7 @@ describe("createTelegramBot", () => {
sendPhotoSpy.mockReset();
setMessageReactionSpy.mockReset();
answerCallbackQuerySpy.mockReset();
editMessageTextSpy.mockReset();
setMyCommandsSpy.mockReset();
wasSentByBot.mockReset();
middlewareUseSpy.mockReset();
@ -424,6 +428,87 @@ describe("createTelegramBot", () => {
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-2");
});
it("edits commands list for pagination callbacks", async () => {
onSpy.mockReset();
listSkillCommandsForAgents.mockReset();
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
expect(callbackHandler).toBeDefined();
await callbackHandler({
callbackQuery: {
id: "cbq-3",
data: "commands_page_2:main",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 12,
},
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(listSkillCommandsForAgents).toHaveBeenCalledWith({
cfg: expect.any(Object),
agentIds: ["main"],
});
expect(editMessageTextSpy).toHaveBeenCalledTimes(1);
const [chatId, messageId, text, params] = editMessageTextSpy.mock.calls[0] ?? [];
expect(chatId).toBe(1234);
expect(messageId).toBe(12);
expect(String(text)).toContain(" Commands");
expect(params).toEqual(
expect.objectContaining({
reply_markup: expect.any(Object),
}),
);
});
it("blocks pagination callbacks when allowlist rejects sender", async () => {
onSpy.mockReset();
editMessageTextSpy.mockReset();
createTelegramBot({
token: "tok",
config: {
channels: {
telegram: {
dmPolicy: "pairing",
capabilities: { inlineButtons: "allowlist" },
allowFrom: [],
},
},
},
});
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
expect(callbackHandler).toBeDefined();
await callbackHandler({
callbackQuery: {
id: "cbq-4",
data: "commands_page_2",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 13,
},
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(editMessageTextSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-4");
});
it("wraps inbound message with Telegram envelope", async () => {
const originalTz = process.env.TZ;
process.env.TZ = "Europe/Vienna";

View File

@ -4,11 +4,13 @@ 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";
@ -140,6 +142,7 @@ export function getCacheStats(): { count: number; oldestAt?: string; newestAt?:
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;
@ -158,31 +161,79 @@ export async function describeStickerImage(params: DescribeStickerParams): Promi
const defaultModel = resolveDefaultModelForAgent({ cfg, agentId });
let activeModel = undefined as { provider: string; model: string } | undefined;
let catalog: ModelCatalogEntry[] = [];
try {
const catalog = await loadModelCatalog({ config: cfg });
catalog = await loadModelCatalog({ config: cfg });
const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model);
if (modelSupportsVision(entry)) {
const supportsVision = modelSupportsVision(entry);
if (supportsVision) {
activeModel = { provider: defaultModel.provider, model: defaultModel.model };
}
} catch {
// Ignore catalog failures; fall back to auto selection.
}
const resolved = await resolveAutoImageModel({
cfg,
agentDir,
activeModel,
});
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;
if (!model) {
logVerbose(`telegram: no vision model available for ${provider}`);
return null;
}
logVerbose(`telegram: describing sticker with ${provider}/${model}`);
try {