From 16c5bd466c4257704cbd5bf8f6eda57d00773ab2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 2 Apr 2026 14:40:51 +0900 Subject: [PATCH] perf(memory): split telegram body helper surface --- .../telegram/src/bot-message-context.body.ts | 4 +- extensions/telegram/src/bot/body-helpers.ts | 309 ++++++++++++++++ extensions/telegram/src/bot/helpers.ts | 342 ++---------------- 3 files changed, 337 insertions(+), 318 deletions(-) create mode 100644 extensions/telegram/src/bot/body-helpers.ts diff --git a/extensions/telegram/src/bot-message-context.body.ts b/extensions/telegram/src/bot-message-context.body.ts index cb9ad7bbb48..77a728953cb 100644 --- a/extensions/telegram/src/bot-message-context.body.ts +++ b/extensions/telegram/src/bot-message-context.body.ts @@ -29,13 +29,13 @@ import type { } from "./bot-message-context.types.js"; import { buildSenderLabel, - buildTelegramGroupPeerId, expandTextLinks, extractTelegramLocation, getTelegramTextParts, hasBotMention, resolveTelegramMediaPlaceholder, -} from "./bot/helpers.js"; +} from "./bot/body-helpers.js"; +import { buildTelegramGroupPeerId } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; import { isTelegramForumServiceMessage } from "./forum-service-message.js"; diff --git a/extensions/telegram/src/bot/body-helpers.ts b/extensions/telegram/src/bot/body-helpers.ts new file mode 100644 index 00000000000..0ef48c93005 --- /dev/null +++ b/extensions/telegram/src/bot/body-helpers.ts @@ -0,0 +1,309 @@ +import type { Chat, Message, MessageOrigin, User } from "@grammyjs/types"; +import type { NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound"; + +export function buildSenderName(msg: Message) { + const name = + [msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ").trim() || + msg.from?.username; + return name || undefined; +} + +export function resolveTelegramMediaPlaceholder( + msg: + | Pick + | undefined + | null, +): string | undefined { + if (!msg) { + return undefined; + } + if (msg.photo) { + return ""; + } + if (msg.video || msg.video_note) { + return ""; + } + if (msg.audio || msg.voice) { + return ""; + } + if (msg.document) { + return ""; + } + if (msg.sticker) { + return ""; + } + return undefined; +} + +export function buildSenderLabel(msg: Message, senderId?: number | string) { + const name = buildSenderName(msg); + const username = msg.from?.username ? `@${msg.from.username}` : undefined; + let label = name; + if (name && username) { + label = `${name} (${username})`; + } else if (!name && username) { + label = username; + } + const normalizedSenderId = + senderId != null && `${senderId}`.trim() ? `${senderId}`.trim() : undefined; + const fallbackId = normalizedSenderId ?? (msg.from?.id != null ? String(msg.from.id) : undefined); + const idPart = fallbackId ? `id:${fallbackId}` : undefined; + if (label && idPart) { + return `${label} ${idPart}`; + } + if (label) { + return label; + } + return idPart ?? "id:unknown"; +} + +export type TelegramTextEntity = NonNullable[number]; + +export function getTelegramTextParts( + msg: Pick, +): { + text: string; + entities: TelegramTextEntity[]; +} { + const text = msg.text ?? msg.caption ?? ""; + const entities = msg.entities ?? msg.caption_entities ?? []; + return { text, entities }; +} + +function isTelegramMentionWordChar(char: string | undefined): boolean { + return char != null && /[a-z0-9_]/i.test(char); +} + +function hasStandaloneTelegramMention(text: string, mention: string): boolean { + let startIndex = 0; + while (startIndex < text.length) { + const idx = text.indexOf(mention, startIndex); + if (idx === -1) { + return false; + } + const prev = idx > 0 ? text[idx - 1] : undefined; + const next = text[idx + mention.length]; + if (!isTelegramMentionWordChar(prev) && !isTelegramMentionWordChar(next)) { + return true; + } + startIndex = idx + 1; + } + return false; +} + +export function hasBotMention(msg: Message, botUsername: string) { + const { text, entities } = getTelegramTextParts(msg); + const mention = `@${botUsername}`.toLowerCase(); + if (hasStandaloneTelegramMention(text.toLowerCase(), mention)) { + return true; + } + for (const ent of entities) { + if (ent.type !== "mention") { + continue; + } + const slice = text.slice(ent.offset, ent.offset + ent.length); + if (slice.toLowerCase() === mention) { + return true; + } + } + 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), + ) + .toSorted((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 type TelegramForwardedContext = { + from: string; + date?: number; + fromType: string; + fromId?: string; + fromUsername?: string; + fromTitle?: string; + fromSignature?: string; + fromChatType?: Chat["type"]; + fromMessageId?: number; +}; + +function normalizeForwardedUserLabel(user: User) { + const name = [user.first_name, user.last_name].filter(Boolean).join(" ").trim(); + const username = user.username?.trim() || undefined; + const id = String(user.id); + const display = + (name && username + ? `${name} (@${username})` + : name || (username ? `@${username}` : undefined)) || `user:${id}`; + return { display, name: name || undefined, username, id }; +} + +function normalizeForwardedChatLabel(chat: Chat, fallbackKind: "chat" | "channel") { + const title = chat.title?.trim() || undefined; + const username = chat.username?.trim() || undefined; + const id = String(chat.id); + const display = title || (username ? `@${username}` : undefined) || `${fallbackKind}:${id}`; + return { display, title, username, id }; +} + +function buildForwardedContextFromUser(params: { + user: User; + date?: number; + type: string; +}): TelegramForwardedContext | null { + const { display, name, username, id } = normalizeForwardedUserLabel(params.user); + if (!display) { + return null; + } + return { + from: display, + date: params.date, + fromType: params.type, + fromId: id, + fromUsername: username, + fromTitle: name, + }; +} + +function buildForwardedContextFromHiddenName(params: { + name?: string; + date?: number; + type: string; +}): TelegramForwardedContext | null { + const trimmed = params.name?.trim(); + if (!trimmed) { + return null; + } + return { + from: trimmed, + date: params.date, + fromType: params.type, + fromTitle: trimmed, + }; +} + +function buildForwardedContextFromChat(params: { + chat: Chat; + date?: number; + type: string; + signature?: string; + messageId?: number; +}): TelegramForwardedContext | null { + const fallbackKind = params.type === "channel" ? "channel" : "chat"; + const { display, title, username, id } = normalizeForwardedChatLabel(params.chat, fallbackKind); + if (!display) { + return null; + } + const signature = params.signature?.trim() || undefined; + const from = signature ? `${display} (${signature})` : display; + const chatType = (params.chat.type?.trim() || undefined) as Chat["type"] | undefined; + return { + from, + date: params.date, + fromType: params.type, + fromId: id, + fromUsername: username, + fromTitle: title, + fromSignature: signature, + fromChatType: chatType, + fromMessageId: params.messageId, + }; +} + +function resolveForwardOrigin(origin: MessageOrigin): TelegramForwardedContext | null { + switch (origin.type) { + case "user": + return buildForwardedContextFromUser({ + user: origin.sender_user, + date: origin.date, + type: "user", + }); + case "hidden_user": + return buildForwardedContextFromHiddenName({ + name: origin.sender_user_name, + date: origin.date, + type: "hidden_user", + }); + case "chat": + return buildForwardedContextFromChat({ + chat: origin.sender_chat, + date: origin.date, + type: "chat", + signature: origin.author_signature, + }); + case "channel": + return buildForwardedContextFromChat({ + chat: origin.chat, + date: origin.date, + type: "channel", + signature: origin.author_signature, + messageId: origin.message_id, + }); + default: + origin satisfies never; + return null; + } +} + +export function normalizeForwardedContext(msg: Message): TelegramForwardedContext | null { + if (!msg.forward_origin) { + return null; + } + return resolveForwardOrigin(msg.forward_origin); +} + +export function extractTelegramLocation(msg: Message): NormalizedLocation | null { + const { venue, location } = msg; + + if (venue) { + return { + latitude: venue.location.latitude, + longitude: venue.location.longitude, + accuracy: venue.location.horizontal_accuracy, + name: venue.title, + address: venue.address, + source: "place", + isLive: false, + }; + } + + if (location) { + const isLive = typeof location.live_period === "number" && location.live_period > 0; + return { + latitude: location.latitude, + longitude: location.longitude, + accuracy: location.horizontal_accuracy, + source: isLive ? "live" : "pin", + isLive, + }; + } + + return null; +} diff --git a/extensions/telegram/src/bot/helpers.ts b/extensions/telegram/src/bot/helpers.ts index 5f5039ad116..969539a9e64 100644 --- a/extensions/telegram/src/bot/helpers.ts +++ b/extensions/telegram/src/bot/helpers.ts @@ -1,4 +1,4 @@ -import type { Chat, Message, MessageOrigin, User } from "@grammyjs/types"; +import type { Chat, Message } from "@grammyjs/types"; import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound"; import { resolveTelegramPreviewStreamMode } from "openclaw/plugin-sdk/config-runtime"; import type { @@ -10,8 +10,32 @@ import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runt import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { firstDefined, normalizeAllowFrom, type NormalizedAllowFrom } from "../bot-access.js"; import { normalizeTelegramReplyToMessageId } from "../outbound-params.js"; +import { + buildSenderLabel, + buildSenderName, + expandTextLinks, + extractTelegramLocation, + getTelegramTextParts, + hasBotMention, + normalizeForwardedContext, + resolveTelegramMediaPlaceholder, + type TelegramForwardedContext, + type TelegramTextEntity, +} from "./body-helpers.js"; import type { TelegramGetChat, TelegramStreamMode } from "./types.js"; +export { + buildSenderLabel, + buildSenderName, + expandTextLinks, + extractTelegramLocation, + getTelegramTextParts, + hasBotMention, + normalizeForwardedContext, + resolveTelegramMediaPlaceholder, +}; +export type { TelegramForwardedContext, TelegramTextEntity } from "./body-helpers.js"; + const TELEGRAM_GENERAL_TOPIC_ID = 1; export type TelegramThreadSpec = { @@ -285,62 +309,6 @@ export function buildTelegramParentPeer(params: { return { kind: "group", id: String(params.chatId) }; } -export function buildSenderName(msg: Message) { - const name = - [msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ").trim() || - msg.from?.username; - return name || undefined; -} - -export function resolveTelegramMediaPlaceholder( - msg: - | Pick - | undefined - | null, -): string | undefined { - if (!msg) { - return undefined; - } - if (msg.photo) { - return ""; - } - if (msg.video || msg.video_note) { - return ""; - } - if (msg.audio || msg.voice) { - return ""; - } - if (msg.document) { - return ""; - } - if (msg.sticker) { - return ""; - } - return undefined; -} - -export function buildSenderLabel(msg: Message, senderId?: number | string) { - const name = buildSenderName(msg); - const username = msg.from?.username ? `@${msg.from.username}` : undefined; - let label = name; - if (name && username) { - label = `${name} (${username})`; - } else if (!name && username) { - label = username; - } - const normalizedSenderId = - senderId != null && `${senderId}`.trim() ? `${senderId}`.trim() : undefined; - const fallbackId = normalizedSenderId ?? (msg.from?.id != null ? String(msg.from.id) : undefined); - const idPart = fallbackId ? `id:${fallbackId}` : undefined; - if (label && idPart) { - return `${label} ${idPart}`; - } - if (label) { - return label; - } - return idPart ?? "id:unknown"; -} - export function buildGroupLabel(msg: Message, chatId: number | string, messageThreadId?: number) { const title = msg.chat?.title; const topicSuffix = messageThreadId != null ? ` topic:${messageThreadId}` : ""; @@ -350,91 +318,6 @@ export function buildGroupLabel(msg: Message, chatId: number | string, messageTh return `group:${chatId}${topicSuffix}`; } -export type TelegramTextEntity = NonNullable[number]; - -export function getTelegramTextParts( - msg: Pick, -): { - text: string; - entities: TelegramTextEntity[]; -} { - const text = msg.text ?? msg.caption ?? ""; - const entities = msg.entities ?? msg.caption_entities ?? []; - return { text, entities }; -} - -function isTelegramMentionWordChar(char: string | undefined): boolean { - return char != null && /[a-z0-9_]/i.test(char); -} - -function hasStandaloneTelegramMention(text: string, mention: string): boolean { - let startIndex = 0; - while (startIndex < text.length) { - const idx = text.indexOf(mention, startIndex); - if (idx === -1) { - return false; - } - const prev = idx > 0 ? text[idx - 1] : undefined; - const next = text[idx + mention.length]; - if (!isTelegramMentionWordChar(prev) && !isTelegramMentionWordChar(next)) { - return true; - } - startIndex = idx + 1; - } - return false; -} - -export function hasBotMention(msg: Message, botUsername: string) { - const { text, entities } = getTelegramTextParts(msg); - const mention = `@${botUsername}`.toLowerCase(); - if (hasStandaloneTelegramMention(text.toLowerCase(), mention)) { - return true; - } - for (const ent of entities) { - if (ent.type !== "mention") { - continue; - } - const slice = text.slice(ent.offset, ent.offset + ent.length); - if (slice.toLowerCase() === mention) { - return true; - } - } - 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), - ) - .toSorted((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 { return normalizeTelegramReplyToMessageId(raw); } @@ -491,9 +374,7 @@ export function describeReplyTarget(msg: Message): TelegramReplyTarget | null { const senderLabel = sender ?? "unknown sender"; // Extract forward context from the resolved reply target (reply_to_message or external_reply). - const forwardedFrom = replyLike?.forward_origin - ? (resolveForwardOrigin(replyLike.forward_origin) ?? undefined) - : undefined; + const forwardedFrom = replyLike ? (normalizeForwardedContext(replyLike) ?? undefined) : undefined; return { id: replyLike?.message_id ? String(replyLike.message_id) : undefined, @@ -503,174 +384,3 @@ export function describeReplyTarget(msg: Message): TelegramReplyTarget | null { forwardedFrom, }; } - -export type TelegramForwardedContext = { - from: string; - date?: number; - fromType: string; - fromId?: string; - fromUsername?: string; - fromTitle?: string; - fromSignature?: string; - /** Original chat type from forward_from_chat (e.g. "channel", "supergroup", "group"). */ - fromChatType?: Chat["type"]; - /** Original message ID in the source chat (channel forwards). */ - fromMessageId?: number; -}; - -function normalizeForwardedUserLabel(user: User) { - const name = [user.first_name, user.last_name].filter(Boolean).join(" ").trim(); - const username = user.username?.trim() || undefined; - const id = String(user.id); - const display = - (name && username - ? `${name} (@${username})` - : name || (username ? `@${username}` : undefined)) || `user:${id}`; - return { display, name: name || undefined, username, id }; -} - -function normalizeForwardedChatLabel(chat: Chat, fallbackKind: "chat" | "channel") { - const title = chat.title?.trim() || undefined; - const username = chat.username?.trim() || undefined; - const id = String(chat.id); - const display = title || (username ? `@${username}` : undefined) || `${fallbackKind}:${id}`; - return { display, title, username, id }; -} - -function buildForwardedContextFromUser(params: { - user: User; - date?: number; - type: string; -}): TelegramForwardedContext | null { - const { display, name, username, id } = normalizeForwardedUserLabel(params.user); - if (!display) { - return null; - } - return { - from: display, - date: params.date, - fromType: params.type, - fromId: id, - fromUsername: username, - fromTitle: name, - }; -} - -function buildForwardedContextFromHiddenName(params: { - name?: string; - date?: number; - type: string; -}): TelegramForwardedContext | null { - const trimmed = params.name?.trim(); - if (!trimmed) { - return null; - } - return { - from: trimmed, - date: params.date, - fromType: params.type, - fromTitle: trimmed, - }; -} - -function buildForwardedContextFromChat(params: { - chat: Chat; - date?: number; - type: string; - signature?: string; - messageId?: number; -}): TelegramForwardedContext | null { - const fallbackKind = params.type === "channel" ? "channel" : "chat"; - const { display, title, username, id } = normalizeForwardedChatLabel(params.chat, fallbackKind); - if (!display) { - return null; - } - const signature = params.signature?.trim() || undefined; - const from = signature ? `${display} (${signature})` : display; - const chatType = (params.chat.type?.trim() || undefined) as Chat["type"] | undefined; - return { - from, - date: params.date, - fromType: params.type, - fromId: id, - fromUsername: username, - fromTitle: title, - fromSignature: signature, - fromChatType: chatType, - fromMessageId: params.messageId, - }; -} - -function resolveForwardOrigin(origin: MessageOrigin): TelegramForwardedContext | null { - switch (origin.type) { - case "user": - return buildForwardedContextFromUser({ - user: origin.sender_user, - date: origin.date, - type: "user", - }); - case "hidden_user": - return buildForwardedContextFromHiddenName({ - name: origin.sender_user_name, - date: origin.date, - type: "hidden_user", - }); - case "chat": - return buildForwardedContextFromChat({ - chat: origin.sender_chat, - date: origin.date, - type: "chat", - signature: origin.author_signature, - }); - case "channel": - return buildForwardedContextFromChat({ - chat: origin.chat, - date: origin.date, - type: "channel", - signature: origin.author_signature, - messageId: origin.message_id, - }); - default: - // Exhaustiveness guard: if Grammy adds a new MessageOrigin variant, - // TypeScript will flag this assignment as an error. - origin satisfies never; - return null; - } -} - -/** Extract forwarded message origin info from Telegram message. */ -export function normalizeForwardedContext(msg: Message): TelegramForwardedContext | null { - if (!msg.forward_origin) { - return null; - } - return resolveForwardOrigin(msg.forward_origin); -} - -export function extractTelegramLocation(msg: Message): NormalizedLocation | null { - const { venue, location } = msg; - - if (venue) { - return { - latitude: venue.location.latitude, - longitude: venue.location.longitude, - accuracy: venue.location.horizontal_accuracy, - name: venue.title, - address: venue.address, - source: "place", - isLive: false, - }; - } - - if (location) { - const isLive = typeof location.live_period === "number" && location.live_period > 0; - return { - latitude: location.latitude, - longitude: location.longitude, - accuracy: location.horizontal_accuracy, - source: isLive ? "live" : "pin", - isLive, - }; - } - - return null; -}