diff --git a/CHANGELOG.md b/CHANGELOG.md index 92bf68fba..68540eb61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.clawd.bot - Status: show both usage windows with reset hints when usage data is available. (#1101) — thanks @rhjoh. - Memory: probe sqlite-vec availability in `clawdbot memory status`. - Memory: split embedding batches to avoid OpenAI token limits during indexing. +- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118) — thanks @sleontenko. ## 2026.1.16-2 diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index b38822a31..69ac146bd 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -27,6 +27,7 @@ import { buildTelegramGroupFrom, buildTelegramGroupPeerId, buildTypingThreadParams, + expandTextLinks, normalizeForwardedContext, describeReplyTarget, extractTelegramLocation, @@ -271,7 +272,8 @@ export const buildTelegramMessageContext = async ({ const locationData = extractTelegramLocation(msg); const locationText = locationData ? formatLocationText(locationData) : undefined; - const rawText = (msg.text ?? msg.caption ?? "").trim(); + const rawTextSource = msg.text ?? msg.caption ?? ""; + const rawText = expandTextLinks(rawTextSource, msg.entities ?? msg.caption_entities).trim(); let rawBody = [rawText, locationText].filter(Boolean).join("\n").trim(); if (!rawBody) rawBody = placeholder; if (!rawBody && allMedia.length === 0) return null; diff --git a/src/telegram/bot/helpers.expand-text-links.test.ts b/src/telegram/bot/helpers.expand-text-links.test.ts new file mode 100644 index 000000000..1e7dd7494 --- /dev/null +++ b/src/telegram/bot/helpers.expand-text-links.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { expandTextLinks } from "./helpers.js"; + +describe("expandTextLinks", () => { + it("returns text unchanged when no entities are provided", () => { + expect(expandTextLinks("Hello world")).toBe("Hello world"); + expect(expandTextLinks("Hello world", null)).toBe("Hello world"); + expect(expandTextLinks("Hello world", [])).toBe("Hello world"); + }); + + it("returns text unchanged when there are no text_link entities", () => { + const entities = [ + { type: "mention", offset: 0, length: 5 }, + { type: "bold", offset: 6, length: 5 }, + ]; + expect(expandTextLinks("@user hello", entities)).toBe("@user hello"); + }); + + it("expands a single text_link entity", () => { + const text = "Check this link for details"; + const entities = [{ type: "text_link", offset: 11, length: 4, url: "https://example.com" }]; + expect(expandTextLinks(text, entities)).toBe( + "Check this [link](https://example.com) for details", + ); + }); + + it("expands multiple text_link entities", () => { + const text = "Visit Google or GitHub for more"; + const entities = [ + { type: "text_link", offset: 6, length: 6, url: "https://google.com" }, + { type: "text_link", offset: 16, length: 6, url: "https://github.com" }, + ]; + expect(expandTextLinks(text, entities)).toBe( + "Visit [Google](https://google.com) or [GitHub](https://github.com) for more", + ); + }); + + it("handles adjacent text_link entities", () => { + const text = "AB"; + const entities = [ + { type: "text_link", offset: 0, length: 1, url: "https://a.example" }, + { type: "text_link", offset: 1, length: 1, url: "https://b.example" }, + ]; + expect(expandTextLinks(text, entities)).toBe("[A](https://a.example)[B](https://b.example)"); + }); + + it("preserves offsets from the original string", () => { + const text = " Hello world"; + const entities = [{ type: "text_link", offset: 1, length: 5, url: "https://example.com" }]; + expect(expandTextLinks(text, entities)).toBe( + " [Hello](https://example.com) world", + ); + }); +}); diff --git a/src/telegram/bot/helpers.ts b/src/telegram/bot/helpers.ts index f068a71ed..ce3c0e123 100644 --- a/src/telegram/bot/helpers.ts +++ b/src/telegram/bot/helpers.ts @@ -114,6 +114,37 @@ export function hasBotMention(msg: TelegramMessage, botUsername: string) { return false; } +type TelegramTextLinkEntity = { + type: string; + offset: number; + length: number; + url?: string; +}; + +export function expandTextLinks( + text: string, + entities?: TelegramTextLinkEntity[] | null, +): string { + if (!text || !entities?.length) return text; + + const textLinks = entities + .filter( + (entity): entity is TelegramTextLinkEntity & { url: string } => + entity.type === "text_link" && Boolean(entity.url), + ) + .sort((a, b) => b.offset - a.offset); + + if (textLinks.length === 0) return text; + + let result = text; + for (const entity of textLinks) { + const linkText = text.slice(entity.offset, entity.offset + entity.length); + const markdown = `[${linkText}](${entity.url})`; + result = result.slice(0, entity.offset) + markdown + result.slice(entity.offset + entity.length); + } + return result; +} + export function resolveTelegramReplyId(raw?: string): number | undefined { if (!raw) return undefined; const parsed = Number(raw);