feat(telegram): add sticker support with vision caching
Add support for receiving and sending Telegram stickers: Inbound: - Receive static WEBP stickers (skip animated/video) - Process stickers through dedicated vision call for descriptions - Cache vision descriptions to avoid repeated API calls - Graceful error handling for fetch failures Outbound: - Add sticker action to send stickers by fileId - Add sticker-search action to find cached stickers by query - Accept stickerId from shared schema, convert to fileId Cache: - Store sticker metadata (fileId, emoji, setName, description) - Fuzzy search by description, emoji, and set name - Persist to ~/.clawdbot/telegram/sticker-cache.json Config: - Single `channels.telegram.actions.sticker` option enables both send and search actions 🤖 AI-assisted: Built with Claude Code (claude-opus-4-5) Testing: Fully tested - unit tests pass, live tested on dev gateway The contributor understands and has reviewed all code changes. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -383,6 +383,129 @@ 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 fields available when receiving stickers:
|
||||||
|
- `StickerEmoji` — the emoji associated with the sticker
|
||||||
|
- `StickerSetName` — the name of the sticker set
|
||||||
|
- `StickerFileId` — the Telegram file ID (used for sending the same sticker back)
|
||||||
|
|
||||||
|
### 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...",
|
||||||
|
"emoji": "👋",
|
||||||
|
"setName": "CoolCats",
|
||||||
|
"description": "A cartoon cat waving enthusiastically",
|
||||||
|
"addedAt": "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 `StickerFileId` 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)
|
## Streaming (drafts)
|
||||||
Telegram can stream **draft bubbles** while the agent is generating a response.
|
Telegram can stream **draft bubbles** while the agent is generating a response.
|
||||||
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
|
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
|
||||||
@@ -537,6 +660,7 @@ Provider options:
|
|||||||
- `channels.telegram.actions.reactions`: gate Telegram tool reactions.
|
- `channels.telegram.actions.reactions`: gate Telegram tool reactions.
|
||||||
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
|
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
|
||||||
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
|
- `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.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).
|
- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `minimal` when not set).
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
editMessageTelegram,
|
editMessageTelegram,
|
||||||
reactMessageTelegram,
|
reactMessageTelegram,
|
||||||
sendMessageTelegram,
|
sendMessageTelegram,
|
||||||
|
sendStickerTelegram,
|
||||||
} from "../../telegram/send.js";
|
} from "../../telegram/send.js";
|
||||||
|
import { getCacheStats, searchStickers } from "../../telegram/sticker-cache.js";
|
||||||
import { resolveTelegramToken } from "../../telegram/token.js";
|
import { resolveTelegramToken } from "../../telegram/token.js";
|
||||||
import {
|
import {
|
||||||
resolveTelegramInlineButtonsScope,
|
resolveTelegramInlineButtonsScope,
|
||||||
@@ -255,5 +257,64 @@ export async function handleTelegramAction(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === "sendSticker") {
|
||||||
|
if (!isActionEnabled("sticker")) {
|
||||||
|
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")) {
|
||||||
|
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}`);
|
throw new Error(`Unsupported Telegram action: ${action}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ChannelId } from "../channels/plugins/types.js";
|
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 { InternalMessageChannel } from "../utils/message-channel.js";
|
||||||
import type { CommandArgs } from "./commands-registry.types.js";
|
import type { CommandArgs } from "./commands-registry.types.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -64,6 +65,8 @@ export type MsgContext = {
|
|||||||
MediaPaths?: string[];
|
MediaPaths?: string[];
|
||||||
MediaUrls?: string[];
|
MediaUrls?: string[];
|
||||||
MediaTypes?: string[];
|
MediaTypes?: string[];
|
||||||
|
/** Telegram sticker metadata (emoji, set name, file IDs, cached description). */
|
||||||
|
Sticker?: StickerMetadata;
|
||||||
OutputDir?: string;
|
OutputDir?: string;
|
||||||
OutputBase?: string;
|
OutputBase?: string;
|
||||||
/** Remote host for SCP when media lives on a different machine (e.g., clawdbot@192.168.64.3). */
|
/** Remote host for SCP when media lives on a different machine (e.g., clawdbot@192.168.64.3). */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
createActionGate,
|
createActionGate,
|
||||||
readNumberParam,
|
readNumberParam,
|
||||||
|
readStringArrayParam,
|
||||||
readStringOrNumberParam,
|
readStringOrNumberParam,
|
||||||
readStringParam,
|
readStringParam,
|
||||||
} from "../../../agents/tools/common.js";
|
} from "../../../agents/tools/common.js";
|
||||||
@@ -45,6 +46,10 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
|||||||
if (gate("reactions")) actions.add("react");
|
if (gate("reactions")) actions.add("react");
|
||||||
if (gate("deleteMessage")) actions.add("delete");
|
if (gate("deleteMessage")) actions.add("delete");
|
||||||
if (gate("editMessage")) actions.add("edit");
|
if (gate("editMessage")) actions.add("edit");
|
||||||
|
if (gate("sticker")) {
|
||||||
|
actions.add("sticker");
|
||||||
|
actions.add("sticker-search");
|
||||||
|
}
|
||||||
return Array.from(actions);
|
return Array.from(actions);
|
||||||
},
|
},
|
||||||
supportsButtons: ({ cfg }) => {
|
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}.`);
|
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
|
|||||||
"thread-reply",
|
"thread-reply",
|
||||||
"search",
|
"search",
|
||||||
"sticker",
|
"sticker",
|
||||||
|
"sticker-search",
|
||||||
"member-info",
|
"member-info",
|
||||||
"role-info",
|
"role-info",
|
||||||
"emoji-list",
|
"emoji-list",
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export type TelegramActionConfig = {
|
|||||||
sendMessage?: boolean;
|
sendMessage?: boolean;
|
||||||
deleteMessage?: boolean;
|
deleteMessage?: boolean;
|
||||||
editMessage?: boolean;
|
editMessage?: boolean;
|
||||||
|
/** Enable sticker actions (send and search). */
|
||||||
|
sticker?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TelegramNetworkConfig = {
|
export type TelegramNetworkConfig = {
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export const TelegramAccountSchemaBase = z
|
|||||||
reactions: z.boolean().optional(),
|
reactions: z.boolean().optional(),
|
||||||
sendMessage: z.boolean().optional(),
|
sendMessage: z.boolean().optional(),
|
||||||
deleteMessage: z.boolean().optional(),
|
deleteMessage: z.boolean().optional(),
|
||||||
|
sticker: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
|
|||||||
"thread-reply": "to",
|
"thread-reply": "to",
|
||||||
search: "none",
|
search: "none",
|
||||||
sticker: "to",
|
sticker: "to",
|
||||||
|
"sticker-search": "none",
|
||||||
"member-info": "none",
|
"member-info": "none",
|
||||||
"role-info": "none",
|
"role-info": "none",
|
||||||
"emoji-list": "none",
|
"emoji-list": "none",
|
||||||
|
|||||||
@@ -112,11 +112,19 @@ export const registerTelegramHandlers = ({
|
|||||||
const captionMsg = entry.messages.find((m) => m.msg.caption || m.msg.text);
|
const captionMsg = entry.messages.find((m) => m.msg.caption || m.msg.text);
|
||||||
const primaryEntry = captionMsg ?? entry.messages[0];
|
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) {
|
for (const { ctx } of entry.messages) {
|
||||||
const media = await resolveMedia(ctx, mediaMaxBytes, opts.token, opts.proxyFetch);
|
const media = await resolveMedia(ctx, mediaMaxBytes, opts.token, opts.proxyFetch);
|
||||||
if (media) {
|
if (media) {
|
||||||
allMedia.push({ path: media.path, contentType: media.contentType });
|
allMedia.push({
|
||||||
|
path: media.path,
|
||||||
|
contentType: media.contentType,
|
||||||
|
stickerMetadata: media.stickerMetadata,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,7 +603,24 @@ export const registerTelegramHandlers = ({
|
|||||||
}
|
}
|
||||||
throw mediaErr;
|
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 senderId = msg.from?.id ? String(msg.from.id) : "";
|
||||||
const conversationKey =
|
const conversationKey =
|
||||||
resolvedThreadId != null ? `${chatId}:topic:${resolvedThreadId}` : String(chatId);
|
resolvedThreadId != null ? `${chatId}:topic:${resolvedThreadId}` : String(chatId);
|
||||||
|
|||||||
@@ -49,7 +49,17 @@ import {
|
|||||||
import { upsertTelegramPairingRequest } from "./pairing-store.js";
|
import { upsertTelegramPairingRequest } from "./pairing-store.js";
|
||||||
import type { TelegramContext } from "./bot/types.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 = {
|
type TelegramMessageContextOptions = {
|
||||||
forceWasMentioned?: boolean;
|
forceWasMentioned?: boolean;
|
||||||
@@ -302,6 +312,18 @@ export const buildTelegramMessageContext = async ({
|
|||||||
else if (msg.video) placeholder = "<media:video>";
|
else if (msg.video) placeholder = "<media:video>";
|
||||||
else if (msg.audio || msg.voice) placeholder = "<media:audio>";
|
else if (msg.audio || msg.voice) placeholder = "<media:audio>";
|
||||||
else if (msg.document) placeholder = "<media:document>";
|
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 stickerCacheHit = Boolean(cachedStickerDescription);
|
||||||
|
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 locationData = extractTelegramLocation(msg);
|
||||||
const locationText = locationData ? formatLocationText(locationData) : undefined;
|
const locationText = locationData ? formatLocationText(locationData) : undefined;
|
||||||
@@ -525,15 +547,26 @@ export const buildTelegramMessageContext = async ({
|
|||||||
ForwardedDate: forwardOrigin?.date ? forwardOrigin.date * 1000 : undefined,
|
ForwardedDate: forwardOrigin?.date ? forwardOrigin.date * 1000 : undefined,
|
||||||
Timestamp: msg.date ? msg.date * 1000 : undefined,
|
Timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||||
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
|
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
|
||||||
MediaPath: allMedia[0]?.path,
|
// Filter out cached stickers from media - their description is already in the message body
|
||||||
MediaType: allMedia[0]?.contentType,
|
MediaPath: stickerCacheHit ? undefined : allMedia[0]?.path,
|
||||||
MediaUrl: allMedia[0]?.path,
|
MediaType: stickerCacheHit ? undefined : allMedia[0]?.contentType,
|
||||||
MediaPaths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
|
MediaUrl: stickerCacheHit ? undefined : allMedia[0]?.path,
|
||||||
MediaUrls: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
|
MediaPaths: stickerCacheHit
|
||||||
MediaTypes:
|
? undefined
|
||||||
allMedia.length > 0
|
: 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[])
|
? (allMedia.map((m) => m.contentType).filter(Boolean) as string[])
|
||||||
: undefined,
|
: undefined,
|
||||||
|
Sticker: allMedia[0]?.stickerMetadata,
|
||||||
...(locationData ? toLocationContext(locationData) : undefined),
|
...(locationData ? toLocationContext(locationData) : undefined),
|
||||||
CommandAuthorized: commandAuthorized,
|
CommandAuthorized: commandAuthorized,
|
||||||
MessageThreadId: resolvedThreadId,
|
MessageThreadId: resolvedThreadId,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
|||||||
import { deliverReplies } from "./bot/delivery.js";
|
import { deliverReplies } from "./bot/delivery.js";
|
||||||
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
|
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
|
||||||
import { createTelegramDraftStream } from "./draft-stream.js";
|
import { createTelegramDraftStream } from "./draft-stream.js";
|
||||||
|
import { cacheSticker, describeStickerImage } from "./sticker-cache.js";
|
||||||
|
import { resolveAgentDir } from "../agents/agent-scope.js";
|
||||||
|
|
||||||
export const dispatchTelegramMessage = async ({
|
export const dispatchTelegramMessage = async ({
|
||||||
context,
|
context,
|
||||||
@@ -128,6 +130,49 @@ export const dispatchTelegramMessage = async ({
|
|||||||
});
|
});
|
||||||
const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId);
|
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 && !sticker.cachedDescription && ctxPayload.MediaPath) {
|
||||||
|
const agentDir = resolveAgentDir(cfg, route.agentId);
|
||||||
|
const description = await describeStickerImage({
|
||||||
|
imagePath: ctxPayload.MediaPath,
|
||||||
|
cfg,
|
||||||
|
agentDir,
|
||||||
|
});
|
||||||
|
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}`;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// 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({
|
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg,
|
cfg,
|
||||||
@@ -139,6 +184,7 @@ export const dispatchTelegramMessage = async ({
|
|||||||
await flushDraft();
|
await flushDraft();
|
||||||
draftStream?.stop();
|
draftStream?.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
await deliverReplies({
|
await deliverReplies({
|
||||||
replies: [payload],
|
replies: [payload],
|
||||||
chatId: String(chatId),
|
chatId: String(chatId),
|
||||||
|
|||||||
@@ -405,6 +405,202 @@ describe("telegram media groups", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("telegram stickers", () => {
|
||||||
|
const STICKER_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 20_000;
|
||||||
|
|
||||||
|
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(
|
||||||
|
"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", () => {
|
describe("telegram text fragments", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import { loadWebMedia } from "../../web/media.js";
|
|||||||
import { buildInlineKeyboard } from "../send.js";
|
import { buildInlineKeyboard } from "../send.js";
|
||||||
import { resolveTelegramVoiceSend } from "../voice.js";
|
import { resolveTelegramVoiceSend } from "../voice.js";
|
||||||
import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js";
|
import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js";
|
||||||
import type { TelegramContext } from "./types.js";
|
import type { StickerMetadata, TelegramContext } from "./types.js";
|
||||||
|
import { getCachedSticker } from "../sticker-cache.js";
|
||||||
|
|
||||||
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
|
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
|
||||||
const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
|
const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
|
||||||
@@ -261,8 +262,79 @@ export async function resolveMedia(
|
|||||||
maxBytes: number,
|
maxBytes: number,
|
||||||
token: string,
|
token: string,
|
||||||
proxyFetch?: typeof fetch,
|
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;
|
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}`);
|
||||||
|
return {
|
||||||
|
path: saved.path,
|
||||||
|
contentType: saved.contentType,
|
||||||
|
placeholder: "<media:sticker>",
|
||||||
|
stickerMetadata: {
|
||||||
|
emoji: cached.emoji,
|
||||||
|
setName: cached.setName,
|
||||||
|
fileId: cached.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: ${err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const m =
|
const m =
|
||||||
msg.photo?.[msg.photo.length - 1] ?? msg.video ?? msg.document ?? msg.audio ?? msg.voice;
|
msg.photo?.[msg.photo.length - 1] ?? msg.video ?? msg.document ?? msg.audio ?? msg.voice;
|
||||||
if (!m?.file_id) return null;
|
if (!m?.file_id) return null;
|
||||||
|
|||||||
@@ -67,3 +67,17 @@ export interface TelegramVenue {
|
|||||||
google_place_id?: string;
|
google_place_id?: string;
|
||||||
google_place_type?: 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: {
|
botApi: {
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
setMessageReaction: vi.fn(),
|
setMessageReaction: vi.fn(),
|
||||||
|
sendSticker: vi.fn(),
|
||||||
},
|
},
|
||||||
botCtorSpy: 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", () => {
|
describe("buildInlineKeyboard", () => {
|
||||||
it("returns undefined for empty input", () => {
|
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";
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
201
src/telegram/sticker-cache.ts
Normal file
201
src/telegram/sticker-cache.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
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 { resolveApiKeyForProvider } from "../agents/model-auth.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 = ["anthropic", "openai", "google", "minimax"] as const;
|
||||||
|
const DEFAULT_VISION_MODELS: Record<string, string> = {
|
||||||
|
anthropic: "claude-sonnet-4-20250514",
|
||||||
|
openai: "gpt-4o-mini",
|
||||||
|
google: "gemini-2.0-flash",
|
||||||
|
minimax: "MiniMax-VL-01",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DescribeStickerParams {
|
||||||
|
imagePath: string;
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
agentDir?: 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 } = params;
|
||||||
|
|
||||||
|
// Find a vision provider with available API key
|
||||||
|
let provider: string | null = null;
|
||||||
|
for (const p of VISION_PROVIDERS) {
|
||||||
|
try {
|
||||||
|
await resolveApiKeyForProvider({ provider: p, cfg, agentDir });
|
||||||
|
provider = p;
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
// No key for this provider, try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
logVerbose("telegram: no vision provider available for sticker description");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = DEFAULT_VISION_MODELS[provider];
|
||||||
|
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: ${err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user