From 653401774d65f52cd953611851f7735c10443f87 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 25 Jan 2026 07:55:39 +0000 Subject: [PATCH] fix(telegram): honor linkPreview on fallback (#1730) * feat: add notice directive parsing * fix: honor telegram linkPreview config (#1700) (thanks @zerone0x) --- CHANGELOG.md | 1 + docs/channels/grammy.md | 2 +- docs/channels/telegram.md | 1 + docs/gateway/configuration.md | 1 + src/auto-reply/reply/directives.ts | 21 ++++++- src/auto-reply/thinking.ts | 11 ++++ ...send.returns-undefined-empty-input.test.ts | 56 +++++++++++++++++++ src/telegram/send.ts | 16 +++--- 8 files changed, 99 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59ce99755..47ba3497c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.clawd.bot - Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock - Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands - Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg. +- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram - Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal. - Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal. diff --git a/docs/channels/grammy.md b/docs/channels/grammy.md index ff0c92c7a..89e5beed2 100644 --- a/docs/channels/grammy.md +++ b/docs/channels/grammy.md @@ -17,7 +17,7 @@ read_when: - **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`. - **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` is set (otherwise it long-polls). - **Sessions:** direct chats collapse into the agent main session (`agent::`); groups use `agent::telegram:group:`; replies route back to the same channel. -- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`. +- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`. - **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming. - **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index f88f50bb6..eb558cf74 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -525,6 +525,7 @@ Provider options: - `channels.telegram.replyToMode`: `off | first | all` (default: `first`). - `channels.telegram.textChunkLimit`: outbound chunk size (chars). - `channels.telegram.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking. +- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true). - `channels.telegram.streamMode`: `off | partial | block` (draft streaming). - `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB). - `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter). diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 12226e1f3..020ca9c90 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1021,6 +1021,7 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w ], historyLimit: 50, // include last N group messages as context (0 disables) replyToMode: "first", // off | first | all + linkPreview: true, // toggle outbound link previews streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming) draftChunk: { // optional; only for streamMode=block minChars: 200, diff --git a/src/auto-reply/reply/directives.ts b/src/auto-reply/reply/directives.ts index 15b0dcb1a..7fc659266 100644 --- a/src/auto-reply/reply/directives.ts +++ b/src/auto-reply/reply/directives.ts @@ -1,7 +1,8 @@ -import type { ReasoningLevel } from "../thinking.js"; +import type { NoticeLevel, ReasoningLevel } from "../thinking.js"; import { type ElevatedLevel, normalizeElevatedLevel, + normalizeNoticeLevel, normalizeReasoningLevel, normalizeThinkLevel, normalizeVerboseLevel, @@ -112,6 +113,22 @@ export function extractVerboseDirective(body?: string): { }; } +export function extractNoticeDirective(body?: string): { + cleaned: string; + noticeLevel?: NoticeLevel; + rawLevel?: string; + hasDirective: boolean; +} { + if (!body) return { cleaned: "", hasDirective: false }; + const extracted = extractLevelDirective(body, ["notice", "notices"], normalizeNoticeLevel); + return { + cleaned: extracted.cleaned, + noticeLevel: extracted.level, + rawLevel: extracted.rawLevel, + hasDirective: extracted.hasDirective, + }; +} + export function extractElevatedDirective(body?: string): { cleaned: string; elevatedLevel?: ElevatedLevel; @@ -152,5 +169,5 @@ export function extractStatusDirective(body?: string): { return extractSimpleDirective(body, ["status"]); } -export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel }; +export type { ElevatedLevel, NoticeLevel, ReasoningLevel, ThinkLevel, VerboseLevel }; export { extractExecDirective } from "./exec/directive.js"; diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index aabb2cf17..a0f712199 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -1,5 +1,6 @@ export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; export type VerboseLevel = "off" | "on" | "full"; +export type NoticeLevel = "off" | "on" | "full"; export type ElevatedLevel = "off" | "on" | "ask" | "full"; export type ElevatedMode = "off" | "ask" | "full"; export type ReasoningLevel = "off" | "on" | "stream"; @@ -93,6 +94,16 @@ export function normalizeVerboseLevel(raw?: string | null): VerboseLevel | undef return undefined; } +// Normalize system notice flags used to toggle system notifications. +export function normalizeNoticeLevel(raw?: string | null): NoticeLevel | undefined { + if (!raw) return undefined; + const key = raw.toLowerCase(); + if (["off", "false", "no", "0"].includes(key)) return "off"; + if (["full", "all", "everything"].includes(key)) return "full"; + if (["on", "minimal", "true", "yes", "1"].includes(key)) return "on"; + return undefined; +} + // Normalize response-usage display modes used to toggle per-response usage footers. export function normalizeUsageDisplay(raw?: string | null): UsageDisplayLevel | undefined { if (!raw) return undefined; diff --git a/src/telegram/send.returns-undefined-empty-input.test.ts b/src/telegram/send.returns-undefined-empty-input.test.ts index bd83d7461..d659c198b 100644 --- a/src/telegram/send.returns-undefined-empty-input.test.ts +++ b/src/telegram/send.returns-undefined-empty-input.test.ts @@ -152,6 +152,62 @@ describe("sendMessageTelegram", () => { expect(res.messageId).toBe("42"); }); + it("adds link_preview_options when previews are disabled in config", async () => { + const chatId = "123"; + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 7, + chat: { id: chatId }, + }); + const api = { sendMessage } as unknown as { + sendMessage: typeof sendMessage; + }; + + loadConfig.mockReturnValue({ + channels: { telegram: { linkPreview: false } }, + }); + + await sendMessageTelegram(chatId, "hi", { token: "tok", api }); + + expect(sendMessage).toHaveBeenCalledWith(chatId, "hi", { + parse_mode: "HTML", + link_preview_options: { is_disabled: true }, + }); + }); + + it("keeps link_preview_options on plain-text fallback when disabled", async () => { + const chatId = "123"; + const parseErr = new Error( + "400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 9", + ); + const sendMessage = vi + .fn() + .mockRejectedValueOnce(parseErr) + .mockResolvedValueOnce({ + message_id: 42, + chat: { id: chatId }, + }); + const api = { sendMessage } as unknown as { + sendMessage: typeof sendMessage; + }; + + loadConfig.mockReturnValue({ + channels: { telegram: { linkPreview: false } }, + }); + + await sendMessageTelegram(chatId, "_oops_", { + token: "tok", + api, + }); + + expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "oops", { + parse_mode: "HTML", + link_preview_options: { is_disabled: true }, + }); + expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "_oops_", { + link_preview_options: { is_disabled: true }, + }); + }); + it("uses native fetch for BAN compatibility when api is omitted", async () => { const originalFetch = globalThis.fetch; const originalBun = (globalThis as { Bun?: unknown }).Bun; diff --git a/src/telegram/send.ts b/src/telegram/send.ts index b440bbe8a..869e990c6 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -42,8 +42,6 @@ type TelegramSendOpts = { messageThreadId?: number; /** Inline keyboard buttons (reply markup). */ buttons?: Array>; - /** Controls whether link previews are shown. Default: true (previews enabled). */ - linkPreview?: boolean; }; type TelegramSendResult = { @@ -200,8 +198,8 @@ export async function sendMessageTelegram( }); const renderHtmlText = (value: string) => renderTelegramHtmlText(value, { textMode, tableMode }); - // Resolve link preview setting: explicit opt > config > default (enabled). - const linkPreviewEnabled = opts.linkPreview ?? account.config.linkPreview ?? true; + // Resolve link preview setting from config (default: enabled). + const linkPreviewEnabled = account.config.linkPreview ?? true; const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true }; const sendTelegramText = async ( @@ -210,10 +208,14 @@ export async function sendMessageTelegram( fallbackText?: string, ) => { const htmlText = renderHtmlText(rawText); + const baseParams = params ? { ...params } : {}; + if (linkPreviewOptions) { + baseParams.link_preview_options = linkPreviewOptions; + } + const hasBaseParams = Object.keys(baseParams).length > 0; const sendParams = { parse_mode: "HTML" as const, - ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}), - ...params, + ...baseParams, }; const res = await request(() => api.sendMessage(chatId, htmlText, sendParams), "message").catch( async (err) => { @@ -225,7 +227,7 @@ export async function sendMessageTelegram( console.warn(`telegram HTML parse failed, retrying as plain text: ${errText}`); } const fallback = fallbackText ?? rawText; - const plainParams = params && Object.keys(params).length > 0 ? { ...params } : undefined; + const plainParams = hasBaseParams ? baseParams : undefined; return await request( () => plainParams