fix: normalize telegram forwarded context (#1090) (thanks @sleontenko)
This commit is contained in:
@@ -5,6 +5,7 @@ Docs: https://docs.clawd.bot
|
|||||||
## 2026.1.17 (Unreleased)
|
## 2026.1.17 (Unreleased)
|
||||||
|
|
||||||
### Changes
|
### 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: 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.
|
- macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg.
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ export type MsgContext = {
|
|||||||
ReplyToId?: string;
|
ReplyToId?: string;
|
||||||
ReplyToBody?: string;
|
ReplyToBody?: string;
|
||||||
ReplyToSender?: string;
|
ReplyToSender?: string;
|
||||||
|
ForwardedFrom?: string;
|
||||||
|
ForwardedFromType?: string;
|
||||||
|
ForwardedFromId?: string;
|
||||||
|
ForwardedFromUsername?: string;
|
||||||
|
ForwardedFromTitle?: string;
|
||||||
|
ForwardedFromSignature?: string;
|
||||||
|
ForwardedDate?: number;
|
||||||
ThreadStarterBody?: string;
|
ThreadStarterBody?: string;
|
||||||
ThreadLabel?: string;
|
ThreadLabel?: string;
|
||||||
MediaPath?: string;
|
MediaPath?: string;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
buildTelegramGroupFrom,
|
buildTelegramGroupFrom,
|
||||||
buildTelegramGroupPeerId,
|
buildTelegramGroupPeerId,
|
||||||
buildTypingThreadParams,
|
buildTypingThreadParams,
|
||||||
|
normalizeForwardedContext,
|
||||||
describeReplyTarget,
|
describeReplyTarget,
|
||||||
extractTelegramLocation,
|
extractTelegramLocation,
|
||||||
hasBotMention,
|
hasBotMention,
|
||||||
@@ -384,11 +385,17 @@ export const buildTelegramMessageContext = async ({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const replyTarget = describeReplyTarget(msg);
|
const replyTarget = describeReplyTarget(msg);
|
||||||
|
const forwardOrigin = normalizeForwardedContext(msg);
|
||||||
const replySuffix = replyTarget
|
const replySuffix = replyTarget
|
||||||
? `\n\n[Replying to ${replyTarget.sender}${
|
? `\n\n[Replying to ${replyTarget.sender}${
|
||||||
replyTarget.id ? ` id:${replyTarget.id}` : ""
|
replyTarget.id ? ` id:${replyTarget.id}` : ""
|
||||||
}]\n${replyTarget.body}\n[/Replying]`
|
}]\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 groupLabel = isGroup ? buildGroupLabel(msg, chatId, resolvedThreadId) : undefined;
|
||||||
const senderName = buildSenderName(msg);
|
const senderName = buildSenderName(msg);
|
||||||
const conversationLabel = isGroup
|
const conversationLabel = isGroup
|
||||||
@@ -398,7 +405,7 @@ export const buildTelegramMessageContext = async ({
|
|||||||
channel: "Telegram",
|
channel: "Telegram",
|
||||||
from: conversationLabel,
|
from: conversationLabel,
|
||||||
timestamp: msg.date ? msg.date * 1000 : undefined,
|
timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||||
body: `${bodyText}${replySuffix}`,
|
body: `${forwardPrefix}${bodyText}${replySuffix}`,
|
||||||
chatType: isGroup ? "group" : "direct",
|
chatType: isGroup ? "group" : "direct",
|
||||||
sender: {
|
sender: {
|
||||||
name: senderName,
|
name: senderName,
|
||||||
@@ -454,6 +461,13 @@ export const buildTelegramMessageContext = async ({
|
|||||||
ReplyToId: replyTarget?.id,
|
ReplyToId: replyTarget?.id,
|
||||||
ReplyToBody: replyTarget?.body,
|
ReplyToBody: replyTarget?.body,
|
||||||
ReplyToSender: replyTarget?.sender,
|
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,
|
Timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||||
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
|
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
|
||||||
MediaPath: allMedia[0]?.path,
|
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) {
|
if (!isGroup) {
|
||||||
const sessionCfg = cfg.session;
|
const sessionCfg = cfg.session;
|
||||||
const storePath = resolveStorePath(sessionCfg?.store, {
|
const storePath = resolveStorePath(sessionCfg?.store, {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildTelegramThreadParams, buildTypingThreadParams } from "./helpers.js";
|
import {
|
||||||
|
buildTelegramThreadParams,
|
||||||
|
buildTypingThreadParams,
|
||||||
|
normalizeForwardedContext,
|
||||||
|
} from "./helpers.js";
|
||||||
|
|
||||||
describe("buildTelegramThreadParams", () => {
|
describe("buildTelegramThreadParams", () => {
|
||||||
it("omits General topic thread id for message sends", () => {
|
it("omits General topic thread id for message sends", () => {
|
||||||
@@ -28,3 +32,65 @@ describe("buildTypingThreadParams", () => {
|
|||||||
expect(buildTypingThreadParams(42.9)).toEqual({ message_thread_id: 42 });
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { formatLocationText, type NormalizedLocation } from "../../channels/location.js";
|
import { formatLocationText, type NormalizedLocation } from "../../channels/location.js";
|
||||||
import type { TelegramAccountConfig } from "../../config/types.telegram.js";
|
import type { TelegramAccountConfig } from "../../config/types.telegram.js";
|
||||||
import type {
|
import type {
|
||||||
|
TelegramForwardChat,
|
||||||
|
TelegramForwardOrigin,
|
||||||
|
TelegramForwardUser,
|
||||||
|
TelegramForwardedMessage,
|
||||||
TelegramLocation,
|
TelegramLocation,
|
||||||
TelegramMessage,
|
TelegramMessage,
|
||||||
TelegramStreamMode,
|
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 {
|
export function extractTelegramLocation(msg: TelegramMessage): NormalizedLocation | null {
|
||||||
const msgWithLocation = msg as {
|
const msgWithLocation = msg as {
|
||||||
location?: TelegramLocation;
|
location?: TelegramLocation;
|
||||||
|
|||||||
@@ -4,6 +4,42 @@ export type TelegramMessage = Message;
|
|||||||
|
|
||||||
export type TelegramStreamMode = "off" | "partial" | "block";
|
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 = {
|
export type TelegramContext = {
|
||||||
message: TelegramMessage;
|
message: TelegramMessage;
|
||||||
me?: { id?: number; username?: string };
|
me?: { id?: number; username?: string };
|
||||||
|
|||||||
Reference in New Issue
Block a user