diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc1610d7..49448c775 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Docs: https://docs.clawd.bot ## 2026.1.17 (Unreleased) ### Changes +- Telegram: enrich forwarded message context with normalized origin details + legacy fallback. (#1090) — thanks @sleontenko. - macOS: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x. - macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg. diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 6bf5d013e..375ba6759 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -44,6 +44,13 @@ export type MsgContext = { ReplyToId?: string; ReplyToBody?: string; ReplyToSender?: string; + ForwardedFrom?: string; + ForwardedFromType?: string; + ForwardedFromId?: string; + ForwardedFromUsername?: string; + ForwardedFromTitle?: string; + ForwardedFromSignature?: string; + ForwardedDate?: number; ThreadStarterBody?: string; ThreadLabel?: string; MediaPath?: string; diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index 046ea3b6b..b38822a31 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -27,6 +27,7 @@ import { buildTelegramGroupFrom, buildTelegramGroupPeerId, buildTypingThreadParams, + normalizeForwardedContext, describeReplyTarget, extractTelegramLocation, hasBotMention, @@ -384,11 +385,17 @@ export const buildTelegramMessageContext = async ({ : null; const replyTarget = describeReplyTarget(msg); + const forwardOrigin = normalizeForwardedContext(msg); const replySuffix = replyTarget ? `\n\n[Replying to ${replyTarget.sender}${ replyTarget.id ? ` id:${replyTarget.id}` : "" }]\n${replyTarget.body}\n[/Replying]` : ""; + const forwardPrefix = forwardOrigin + ? `[Forwarded from ${forwardOrigin.from}${ + forwardOrigin.date ? ` at ${new Date(forwardOrigin.date * 1000).toISOString()}` : "" + }]\n` + : ""; const groupLabel = isGroup ? buildGroupLabel(msg, chatId, resolvedThreadId) : undefined; const senderName = buildSenderName(msg); const conversationLabel = isGroup @@ -398,7 +405,7 @@ export const buildTelegramMessageContext = async ({ channel: "Telegram", from: conversationLabel, timestamp: msg.date ? msg.date * 1000 : undefined, - body: `${bodyText}${replySuffix}`, + body: `${forwardPrefix}${bodyText}${replySuffix}`, chatType: isGroup ? "group" : "direct", sender: { name: senderName, @@ -454,6 +461,13 @@ export const buildTelegramMessageContext = async ({ ReplyToId: replyTarget?.id, ReplyToBody: replyTarget?.body, ReplyToSender: replyTarget?.sender, + ForwardedFrom: forwardOrigin?.from, + ForwardedFromType: forwardOrigin?.fromType, + ForwardedFromId: forwardOrigin?.fromId, + ForwardedFromUsername: forwardOrigin?.fromUsername, + ForwardedFromTitle: forwardOrigin?.fromTitle, + ForwardedFromSignature: forwardOrigin?.fromSignature, + ForwardedDate: forwardOrigin?.date ? forwardOrigin.date * 1000 : undefined, Timestamp: msg.date ? msg.date * 1000 : undefined, WasMentioned: isGroup ? effectiveWasMentioned : undefined, MediaPath: allMedia[0]?.path, @@ -481,6 +495,12 @@ export const buildTelegramMessageContext = async ({ ); } + if (forwardOrigin && shouldLogVerbose()) { + logVerbose( + `telegram forward-context: forwardedFrom="${forwardOrigin.from}" type=${forwardOrigin.fromType}`, + ); + } + if (!isGroup) { const sessionCfg = cfg.session; const storePath = resolveStorePath(sessionCfg?.store, { diff --git a/src/telegram/bot/helpers.test.ts b/src/telegram/bot/helpers.test.ts index b84204858..48ce7ce35 100644 --- a/src/telegram/bot/helpers.test.ts +++ b/src/telegram/bot/helpers.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { buildTelegramThreadParams, buildTypingThreadParams } from "./helpers.js"; +import { + buildTelegramThreadParams, + buildTypingThreadParams, + normalizeForwardedContext, +} from "./helpers.js"; describe("buildTelegramThreadParams", () => { it("omits General topic thread id for message sends", () => { @@ -28,3 +32,65 @@ describe("buildTypingThreadParams", () => { expect(buildTypingThreadParams(42.9)).toEqual({ message_thread_id: 42 }); }); }); + +describe("normalizeForwardedContext", () => { + it("handles forward_origin users", () => { + const ctx = normalizeForwardedContext({ + forward_origin: { + type: "user", + sender_user: { first_name: "Ada", last_name: "Lovelace", username: "ada", id: 42 }, + date: 123, + }, + } as any); + expect(ctx).not.toBeNull(); + expect(ctx?.from).toBe("Ada Lovelace (@ada)"); + expect(ctx?.fromType).toBe("user"); + expect(ctx?.fromId).toBe("42"); + expect(ctx?.fromUsername).toBe("ada"); + expect(ctx?.fromTitle).toBe("Ada Lovelace"); + expect(ctx?.date).toBe(123); + }); + + it("handles hidden forward_origin names", () => { + const ctx = normalizeForwardedContext({ + forward_origin: { type: "hidden_user", sender_user_name: "Hidden Name", date: 456 }, + } as any); + expect(ctx).not.toBeNull(); + expect(ctx?.from).toBe("Hidden Name"); + expect(ctx?.fromType).toBe("hidden_user"); + expect(ctx?.fromTitle).toBe("Hidden Name"); + expect(ctx?.date).toBe(456); + }); + + it("handles legacy forwards with signatures", () => { + const ctx = normalizeForwardedContext({ + forward_from_chat: { + title: "Clawdbot Updates", + username: "clawdbot", + id: 99, + type: "channel", + }, + forward_signature: "Stan", + forward_date: 789, + } as any); + expect(ctx).not.toBeNull(); + expect(ctx?.from).toBe("Clawdbot Updates (Stan)"); + expect(ctx?.fromType).toBe("legacy_channel"); + expect(ctx?.fromId).toBe("99"); + expect(ctx?.fromUsername).toBe("clawdbot"); + expect(ctx?.fromTitle).toBe("Clawdbot Updates"); + expect(ctx?.fromSignature).toBe("Stan"); + expect(ctx?.date).toBe(789); + }); + + it("handles legacy hidden sender names", () => { + const ctx = normalizeForwardedContext({ + forward_sender_name: "Legacy Hidden", + forward_date: 111, + } as any); + expect(ctx).not.toBeNull(); + expect(ctx?.from).toBe("Legacy Hidden"); + expect(ctx?.fromType).toBe("legacy_hidden_user"); + expect(ctx?.date).toBe(111); + }); +}); diff --git a/src/telegram/bot/helpers.ts b/src/telegram/bot/helpers.ts index ad4411abf..b39152cc6 100644 --- a/src/telegram/bot/helpers.ts +++ b/src/telegram/bot/helpers.ts @@ -1,6 +1,10 @@ import { formatLocationText, type NormalizedLocation } from "../../channels/location.js"; import type { TelegramAccountConfig } from "../../config/types.telegram.js"; import type { + TelegramForwardChat, + TelegramForwardOrigin, + TelegramForwardUser, + TelegramForwardedMessage, TelegramLocation, TelegramMessage, TelegramStreamMode, @@ -142,6 +146,170 @@ export function describeReplyTarget(msg: TelegramMessage) { }; } +export type TelegramForwardedContext = { + from: string; + date?: number; + fromType: string; + fromId?: string; + fromUsername?: string; + fromTitle?: string; + fromSignature?: string; +}; + +function normalizeForwardedUserLabel(user: TelegramForwardUser) { + const name = [user.first_name, user.last_name].filter(Boolean).join(" ").trim(); + const username = user.username?.trim() || undefined; + const id = user.id != null ? String(user.id) : undefined; + const display = + (name && username ? `${name} (@${username})` : name || (username ? `@${username}` : undefined)) || + (id ? `user:${id}` : undefined); + return { display, name: name || undefined, username, id }; +} + +function normalizeForwardedChatLabel(chat: TelegramForwardChat, fallbackKind: "chat" | "channel") { + const title = chat.title?.trim() || undefined; + const username = chat.username?.trim() || undefined; + const id = chat.id != null ? String(chat.id) : undefined; + const display = + title || (username ? `@${username}` : undefined) || (id ? `${fallbackKind}:${id}` : undefined); + return { display, title, username, id }; +} + +function buildForwardedContextFromUser(params: { + user: TelegramForwardUser; + 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: TelegramForwardChat; + date?: number; + type: string; + signature?: string; +}): TelegramForwardedContext | null { + const fallbackKind = params.type === "channel" || params.type === "legacy_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; + return { + from, + date: params.date, + fromType: params.type, + fromId: id, + fromUsername: username, + fromTitle: title, + fromSignature: signature, + }; +} + +function resolveForwardOrigin( + origin: TelegramForwardOrigin, + signature?: string, +): TelegramForwardedContext | null { + if (origin.type === "user" && origin.sender_user) { + return buildForwardedContextFromUser({ + user: origin.sender_user, + date: origin.date, + type: "user", + }); + } + if (origin.type === "hidden_user") { + return buildForwardedContextFromHiddenName({ + name: origin.sender_user_name, + date: origin.date, + type: "hidden_user", + }); + } + if (origin.type === "chat" && origin.sender_chat) { + return buildForwardedContextFromChat({ + chat: origin.sender_chat, + date: origin.date, + type: "chat", + signature, + }); + } + if (origin.type === "channel" && origin.chat) { + return buildForwardedContextFromChat({ + chat: origin.chat, + date: origin.date, + type: "channel", + signature, + }); + } + return null; +} + +/** + * Extract forwarded message origin info from Telegram message. + * Supports both new forward_origin API and legacy forward_from/forward_from_chat fields. + */ +export function normalizeForwardedContext(msg: TelegramMessage): TelegramForwardedContext | null { + const forwardMsg = msg as TelegramForwardedMessage; + const signature = forwardMsg.forward_signature?.trim() || undefined; + + if (forwardMsg.forward_origin) { + const originContext = resolveForwardOrigin(forwardMsg.forward_origin, signature); + if (originContext) return originContext; + } + + if (forwardMsg.forward_from_chat) { + const legacyType = + forwardMsg.forward_from_chat.type === "channel" ? "legacy_channel" : "legacy_chat"; + const legacyContext = buildForwardedContextFromChat({ + chat: forwardMsg.forward_from_chat, + date: forwardMsg.forward_date, + type: legacyType, + signature, + }); + if (legacyContext) return legacyContext; + } + + if (forwardMsg.forward_from) { + const legacyContext = buildForwardedContextFromUser({ + user: forwardMsg.forward_from, + date: forwardMsg.forward_date, + type: "legacy_user", + }); + if (legacyContext) return legacyContext; + } + + const hiddenContext = buildForwardedContextFromHiddenName({ + name: forwardMsg.forward_sender_name, + date: forwardMsg.forward_date, + type: "legacy_hidden_user", + }); + if (hiddenContext) return hiddenContext; + + return null; +} + export function extractTelegramLocation(msg: TelegramMessage): NormalizedLocation | null { const msgWithLocation = msg as { location?: TelegramLocation; diff --git a/src/telegram/bot/types.ts b/src/telegram/bot/types.ts index 0d3aec455..1174503b4 100644 --- a/src/telegram/bot/types.ts +++ b/src/telegram/bot/types.ts @@ -4,6 +4,42 @@ export type TelegramMessage = Message; export type TelegramStreamMode = "off" | "partial" | "block"; +export type TelegramForwardOriginType = "user" | "hidden_user" | "chat" | "channel"; + +export type TelegramForwardUser = { + first_name?: string; + last_name?: string; + username?: string; + id?: number; +}; + +export type TelegramForwardChat = { + title?: string; + id?: number; + username?: string; + type?: string; +}; + +export type TelegramForwardOrigin = { + type: TelegramForwardOriginType; + sender_user?: TelegramForwardUser; + sender_user_name?: string; + sender_chat?: TelegramForwardChat; + chat?: TelegramForwardChat; + date?: number; +}; + +export type TelegramForwardMetadata = { + forward_origin?: TelegramForwardOrigin; + forward_from?: TelegramForwardUser; + forward_from_chat?: TelegramForwardChat; + forward_sender_name?: string; + forward_signature?: string; + forward_date?: number; +}; + +export type TelegramForwardedMessage = TelegramMessage & TelegramForwardMetadata; + export type TelegramContext = { message: TelegramMessage; me?: { id?: number; username?: string };