mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
c5d1cb38e2
* fix(channels): preserve inbound attachment filenames Fixes #128956 * test(discord): verify names on successfully downloaded media * test(discord): verify referenced attachment filenames * fix(telegram): preserve accepted resolved-media shapes
944 lines
34 KiB
TypeScript
944 lines
34 KiB
TypeScript
// Telegram plugin module implements bot message context.session behavior.
|
|
import {
|
|
type BuildChannelInboundEventContextParams,
|
|
type BuildChannelInboundEventContextAsyncParams,
|
|
type BuiltChannelInboundEventContext,
|
|
formatMediaPlaceholderText,
|
|
formatInboundEnvelope,
|
|
resolveEnvelopeFormatOptions,
|
|
toLocationContext,
|
|
type NormalizedLocation,
|
|
type InboundEventKind,
|
|
} from "openclaw/plugin-sdk/channel-inbound";
|
|
import { normalizeCommandBody } from "openclaw/plugin-sdk/command-surface";
|
|
import type {
|
|
OpenClawConfig,
|
|
TelegramDirectConfig,
|
|
TelegramGroupConfig,
|
|
TelegramTopicConfig,
|
|
} from "openclaw/plugin-sdk/config-contracts";
|
|
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime";
|
|
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
|
|
import { createChannelHistoryWindow, type HistoryEntry } from "openclaw/plugin-sdk/reply-history";
|
|
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
|
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
|
import { evaluateSupplementalContextVisibility } from "openclaw/plugin-sdk/security-runtime";
|
|
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
|
import type { NormalizedAllowFrom } from "./bot-access.js";
|
|
import { isSenderAllowed, normalizeAllowFrom } from "./bot-access.js";
|
|
import type {
|
|
TelegramMediaRef,
|
|
TelegramMessageContextOptions,
|
|
TelegramMessageContextSessionRuntimeOverrides,
|
|
TelegramPromptContextEntry,
|
|
} from "./bot-message-context.types.js";
|
|
import {
|
|
buildGroupLabel,
|
|
buildSenderLabel,
|
|
buildSenderName,
|
|
buildTelegramGroupFrom,
|
|
buildTelegramInboundOriginTarget,
|
|
buildTelegramParentPeer,
|
|
describeReplyTarget,
|
|
getTelegramTextParts,
|
|
normalizeForwardedContext,
|
|
resolveTelegramPrimaryMedia,
|
|
type TelegramMediaKind,
|
|
type TelegramReplyTarget,
|
|
type TelegramThreadSpec,
|
|
} from "./bot/helpers.js";
|
|
import { renderTelegramTextEntities } from "./bot/inbound-text-entities.js";
|
|
import type { TelegramContext } from "./bot/types.js";
|
|
import { resolveTelegramDirectPeerId } from "./dm-session-key.js";
|
|
import {
|
|
resolveTelegramDirectToolPolicy,
|
|
resolveTelegramGroupPromptSettings,
|
|
} from "./group-config-helpers.js";
|
|
import {
|
|
isTelegramHistoryEntryAfterAmbientWatermark,
|
|
isTelegramChatWindowPromptContext,
|
|
mergeTelegramGroupHistoryPromptContext,
|
|
recordTelegramGroupHistoryEntry,
|
|
retainTelegramGroupHistoryPromptContext,
|
|
selectTelegramGroupHistoryAfterLastSelf,
|
|
} from "./group-history-window.js";
|
|
import { TELEGRAM_REPLY_CHAIN_MAX_DEPTH, type TelegramReplyChainEntry } from "./message-cache.js";
|
|
import { resolveTelegramPromptMediaPath } from "./prompt-media-path.js";
|
|
import { buildTelegramConversationId } from "./topic-conversation.js";
|
|
|
|
type TelegramMentionFacts = NonNullable<
|
|
NonNullable<BuildChannelInboundEventContextParams["access"]>["mentions"]
|
|
>;
|
|
|
|
type TelegramInboundContextPayload = BuiltChannelInboundEventContext & {
|
|
From: string;
|
|
To: string;
|
|
ChatType: string;
|
|
RawBody: string;
|
|
ReplyToIsExternal?: boolean;
|
|
ReplyToQuotePosition?: number;
|
|
ReplyToQuoteEntities?: TelegramReplyTarget["quoteEntities"];
|
|
ReplyToQuoteSourceText?: string;
|
|
ReplyToQuoteSourceEntities?: TelegramReplyTarget["quoteSourceEntities"];
|
|
};
|
|
|
|
type TelegramMessageContextSessionRuntime =
|
|
typeof import("./bot-message-context.session.runtime.js");
|
|
|
|
const sessionRuntimeMethods = [
|
|
"buildChannelInboundEventContext",
|
|
"readAmbientTranscriptWatermark",
|
|
"readSessionUpdatedAt",
|
|
"recordInboundSession",
|
|
"resolveAmbientTranscriptWatermarkKey",
|
|
"resolveInboundLastRouteSessionKey",
|
|
"resolvePinnedMainDmOwnerFromAllowlist",
|
|
"resolveStorePath",
|
|
] as const satisfies readonly (keyof TelegramMessageContextSessionRuntime)[];
|
|
|
|
function hasCompleteSessionRuntime(
|
|
runtime: TelegramMessageContextSessionRuntimeOverrides | undefined,
|
|
): runtime is TelegramMessageContextSessionRuntime {
|
|
return Boolean(
|
|
runtime && sessionRuntimeMethods.every((method) => typeof runtime[method] === "function"),
|
|
);
|
|
}
|
|
|
|
async function loadTelegramMessageContextSessionRuntime(
|
|
runtime: TelegramMessageContextSessionRuntimeOverrides | undefined,
|
|
): Promise<TelegramMessageContextSessionRuntime> {
|
|
if (hasCompleteSessionRuntime(runtime)) {
|
|
return runtime;
|
|
}
|
|
return {
|
|
...(await import("./bot-message-context.session.runtime.js")),
|
|
...runtime,
|
|
};
|
|
}
|
|
|
|
export async function resolveTelegramMessageContextStorePath(params: {
|
|
cfg: OpenClawConfig;
|
|
agentId: string;
|
|
sessionRuntime?: TelegramMessageContextSessionRuntimeOverrides;
|
|
}): Promise<string> {
|
|
const sessionRuntime = await loadTelegramMessageContextSessionRuntime(params.sessionRuntime);
|
|
return sessionRuntime.resolveStorePath(params.cfg.session?.store, {
|
|
agentId: params.agentId,
|
|
});
|
|
}
|
|
|
|
function replyTargetToChainEntry(replyTarget: TelegramReplyTarget): TelegramReplyChainEntry {
|
|
return {
|
|
...(replyTarget.id ? { messageId: replyTarget.id } : {}),
|
|
sender: replyTarget.sender,
|
|
...(replyTarget.senderId ? { senderId: replyTarget.senderId } : {}),
|
|
...(replyTarget.senderUsername ? { senderUsername: replyTarget.senderUsername } : {}),
|
|
...(replyTarget.body ? { body: replyTarget.body } : {}),
|
|
...(replyTarget.mediaType
|
|
? { mediaKind: replyTarget.mediaType, mediaType: replyTarget.mediaType }
|
|
: {}),
|
|
...(replyTarget.kind === "quote" ? { isQuote: true } : {}),
|
|
...(replyTarget.forwardedFrom?.from ? { forwardedFrom: replyTarget.forwardedFrom.from } : {}),
|
|
...(replyTarget.forwardedFrom?.fromId
|
|
? { forwardedFromId: replyTarget.forwardedFrom.fromId }
|
|
: {}),
|
|
...(replyTarget.forwardedFrom?.fromUsername
|
|
? { forwardedFromUsername: replyTarget.forwardedFrom.fromUsername }
|
|
: {}),
|
|
...(replyTarget.forwardedFrom?.date
|
|
? { forwardedDate: replyTarget.forwardedFrom.date * 1000 }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
function stripReplyChainForwarded(entry: TelegramReplyChainEntry): TelegramReplyChainEntry {
|
|
const {
|
|
forwardedFrom: _forwardedFrom,
|
|
forwardedFromId: _forwardedFromId,
|
|
forwardedFromUsername: _forwardedFromUsername,
|
|
forwardedDate: _forwardedDate,
|
|
...withoutForwarded
|
|
} = entry;
|
|
return withoutForwarded;
|
|
}
|
|
|
|
function formatTelegramForwardedMessageBody(params: {
|
|
body: string;
|
|
forwardedFrom?: string;
|
|
forwardedDate?: number;
|
|
}): string {
|
|
const forwardedAt = timestampMsToIsoString(params.forwardedDate);
|
|
const forwardPrefix = params.forwardedFrom
|
|
? `[Forwarded from ${params.forwardedFrom}${forwardedAt ? ` at ${forwardedAt}` : ""}]`
|
|
: undefined;
|
|
return [forwardPrefix, params.body].filter(Boolean).join("\n");
|
|
}
|
|
|
|
function formatReplyChainEntry(entry: TelegramReplyChainEntry, index: number): string {
|
|
const mediaPath = entry.mediaPath ? resolveTelegramPromptMediaPath(entry.mediaPath) : undefined;
|
|
const labels = [
|
|
`${index + 1}. ${entry.sender ?? "unknown sender"}`,
|
|
entry.messageId ? `id:${entry.messageId}` : undefined,
|
|
entry.replyToId ? `reply_to:${entry.replyToId}` : undefined,
|
|
entry.timestamp ? timestampMsToIsoString(entry.timestamp) : undefined,
|
|
].filter(Boolean);
|
|
const bodyLines = [
|
|
formatTelegramForwardedMessageBody({
|
|
body: entry.isQuote && entry.body ? `"${entry.body}"` : (entry.body ?? ""),
|
|
forwardedFrom: entry.forwardedFrom,
|
|
forwardedDate: entry.forwardedDate,
|
|
}),
|
|
entry.mediaKind || entry.mediaType
|
|
? formatMediaPlaceholderText([
|
|
entry.mediaKind
|
|
? { kind: entry.mediaKind }
|
|
: isTelegramMediaKind(entry.mediaType ?? "")
|
|
? { kind: entry.mediaType as TelegramMediaKind }
|
|
: { contentType: entry.mediaType },
|
|
])
|
|
: undefined,
|
|
mediaPath ? `[media_path:${mediaPath}]` : undefined,
|
|
entry.mediaRef ? `[media_ref:${entry.mediaRef}]` : undefined,
|
|
].filter(Boolean);
|
|
return `[${labels.join(" ")}]\n${bodyLines.join("\n")}`;
|
|
}
|
|
|
|
const TELEGRAM_MEDIA_KINDS = new Set<TelegramMediaKind>([
|
|
"audio",
|
|
"document",
|
|
"image",
|
|
"sticker",
|
|
"video",
|
|
]);
|
|
|
|
function isTelegramMediaKind(value: string): value is TelegramMediaKind {
|
|
return TELEGRAM_MEDIA_KINDS.has(value as TelegramMediaKind);
|
|
}
|
|
|
|
export async function buildTelegramInboundContextPayload(params: {
|
|
cfg: OpenClawConfig;
|
|
primaryCtx: TelegramContext;
|
|
msg: TelegramContext["message"];
|
|
allMedia: TelegramMediaRef[];
|
|
replyMedia: TelegramMediaRef[];
|
|
replyChain: TelegramReplyChainEntry[];
|
|
promptContext: TelegramPromptContextEntry[];
|
|
isGroup: boolean;
|
|
isForum: boolean;
|
|
chatId: number | string;
|
|
senderId: string;
|
|
senderUsername: string;
|
|
resolvedThreadId?: number;
|
|
dmThreadId?: number;
|
|
threadSpec: TelegramThreadSpec;
|
|
route: ResolvedAgentRoute;
|
|
rawBody: string;
|
|
bodyText: string;
|
|
historyKey?: string;
|
|
historyLimit: number;
|
|
dmHistoryLimit: number;
|
|
groupHistories: Map<string, HistoryEntry[]>;
|
|
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
|
topicConfig?: TelegramTopicConfig;
|
|
effectiveWasMentioned: boolean;
|
|
inboundEventKind: InboundEventKind;
|
|
groupRequireMention: boolean;
|
|
mentionFacts: TelegramMentionFacts;
|
|
hasControlCommand: boolean;
|
|
stickerCacheHit?: boolean;
|
|
audioTranscribedMediaIndex?: number;
|
|
commandAuthorized: boolean;
|
|
locationData?: NormalizedLocation;
|
|
options?: TelegramMessageContextOptions;
|
|
dmAllowFrom?: Array<string | number>;
|
|
effectiveGroupAllow?: NormalizedAllowFrom;
|
|
topicName?: string;
|
|
sessionRuntime?: TelegramMessageContextSessionRuntimeOverrides;
|
|
}): Promise<{
|
|
ctxPayload: TelegramInboundContextPayload;
|
|
skillFilter: string[] | undefined;
|
|
turn: {
|
|
storePath: string;
|
|
recordInboundSession: TelegramMessageContextSessionRuntime["recordInboundSession"];
|
|
record: {
|
|
updateLastRoute?: Parameters<
|
|
TelegramMessageContextSessionRuntime["recordInboundSession"]
|
|
>[0]["updateLastRoute"];
|
|
onRecordError: (err: unknown) => void;
|
|
};
|
|
};
|
|
}> {
|
|
const {
|
|
cfg,
|
|
primaryCtx,
|
|
msg,
|
|
allMedia,
|
|
replyMedia,
|
|
replyChain,
|
|
promptContext,
|
|
isGroup,
|
|
isForum,
|
|
chatId,
|
|
senderId,
|
|
senderUsername,
|
|
resolvedThreadId,
|
|
dmThreadId,
|
|
threadSpec,
|
|
route,
|
|
rawBody,
|
|
bodyText,
|
|
historyKey,
|
|
historyLimit,
|
|
dmHistoryLimit,
|
|
groupHistories,
|
|
groupConfig,
|
|
topicConfig,
|
|
effectiveWasMentioned,
|
|
inboundEventKind,
|
|
groupRequireMention,
|
|
mentionFacts,
|
|
hasControlCommand,
|
|
stickerCacheHit,
|
|
audioTranscribedMediaIndex,
|
|
commandAuthorized,
|
|
locationData,
|
|
options,
|
|
dmAllowFrom,
|
|
effectiveGroupAllow,
|
|
topicName,
|
|
sessionRuntime: sessionRuntimeOverride,
|
|
} = params;
|
|
const replyTarget = describeReplyTarget(msg);
|
|
const bufferedMessages = options?.bufferedMessages ?? [];
|
|
const hasMultiMessageBatch = bufferedMessages.length > 1;
|
|
const shouldRenderBufferedBody =
|
|
hasMultiMessageBatch && options?.ingressBuffer !== "text-fragment";
|
|
const forwardOrigin = shouldRenderBufferedBody ? null : normalizeForwardedContext(msg);
|
|
const contextVisibilityMode = resolveChannelContextVisibilityMode({
|
|
cfg,
|
|
channel: "telegram",
|
|
accountId: route.accountId,
|
|
});
|
|
const shouldIncludeGroupSupplementalContext = (paramsLocal: {
|
|
kind: "quote" | "forwarded";
|
|
senderId?: string;
|
|
senderUsername?: string;
|
|
}): boolean => {
|
|
if (!isGroup) {
|
|
return true;
|
|
}
|
|
const senderAllowed = effectiveGroupAllow?.hasEntries
|
|
? isSenderAllowed({
|
|
allow: effectiveGroupAllow,
|
|
senderId: paramsLocal.senderId,
|
|
senderUsername: paramsLocal.senderUsername,
|
|
})
|
|
: true;
|
|
return evaluateSupplementalContextVisibility({
|
|
mode: contextVisibilityMode,
|
|
kind: paramsLocal.kind,
|
|
senderAllowed,
|
|
}).include;
|
|
};
|
|
// Single owner for reply-target visibility so every buffered message in a
|
|
// synthetic batch is gated identically to the lone-message case. Without one
|
|
// owner, only the synthetic (first) message's reply/quote survives the merge.
|
|
const resolveVisibleReplyTarget = (
|
|
target: TelegramReplyTarget | null,
|
|
): TelegramReplyTarget | null => {
|
|
if (
|
|
!target ||
|
|
!shouldIncludeGroupSupplementalContext({
|
|
kind: "quote",
|
|
senderId: target.senderId,
|
|
senderUsername: target.senderUsername,
|
|
})
|
|
) {
|
|
return null;
|
|
}
|
|
const forwardedFrom =
|
|
target.forwardedFrom &&
|
|
shouldIncludeGroupSupplementalContext({
|
|
kind: "forwarded",
|
|
senderId: target.forwardedFrom.fromId,
|
|
senderUsername: target.forwardedFrom.fromUsername,
|
|
})
|
|
? target.forwardedFrom
|
|
: undefined;
|
|
return { ...target, forwardedFrom };
|
|
};
|
|
const includeForwardOrigin = forwardOrigin
|
|
? shouldIncludeGroupSupplementalContext({
|
|
kind: "forwarded",
|
|
senderId: forwardOrigin.fromId,
|
|
senderUsername: forwardOrigin.fromUsername,
|
|
})
|
|
: false;
|
|
const visibleReplyTarget = resolveVisibleReplyTarget(replyTarget);
|
|
const visibleReplyTargetEntry = visibleReplyTarget
|
|
? replyTargetToChainEntry(visibleReplyTarget)
|
|
: undefined;
|
|
const inheritedReplyChain =
|
|
replyChain.length > 0 ? replyChain : visibleReplyTargetEntry ? [visibleReplyTargetEntry] : [];
|
|
const seenReplyMessageIds = new Set<string>();
|
|
const rawReplyChain: TelegramReplyChainEntry[] = [];
|
|
const appendReplyChainEntry = (entry: TelegramReplyChainEntry | undefined) => {
|
|
if (!entry || rawReplyChain.length >= TELEGRAM_REPLY_CHAIN_MAX_DEPTH) {
|
|
return;
|
|
}
|
|
if (entry.messageId !== undefined) {
|
|
if (seenReplyMessageIds.has(entry.messageId)) {
|
|
return;
|
|
}
|
|
seenReplyMessageIds.add(entry.messageId);
|
|
}
|
|
rawReplyChain.push(entry);
|
|
};
|
|
// The synthetic message already owns the first entry's cached ancestry.
|
|
// Recover only its erased tail; newest direct targets outrank older ancestry.
|
|
for (
|
|
let index = bufferedMessages.length - 1;
|
|
index >= 1 && rawReplyChain.length < TELEGRAM_REPLY_CHAIN_MAX_DEPTH;
|
|
index -= 1
|
|
) {
|
|
const bufferedMessage = bufferedMessages[index];
|
|
if (!bufferedMessage) {
|
|
continue;
|
|
}
|
|
const visible = resolveVisibleReplyTarget(describeReplyTarget(bufferedMessage));
|
|
appendReplyChainEntry(visible ? replyTargetToChainEntry(visible) : undefined);
|
|
}
|
|
for (const entry of inheritedReplyChain) {
|
|
if (rawReplyChain.length >= TELEGRAM_REPLY_CHAIN_MAX_DEPTH) {
|
|
break;
|
|
}
|
|
appendReplyChainEntry(entry);
|
|
}
|
|
const visibleReplyChain = rawReplyChain.flatMap((entry) => {
|
|
const selectedReplyEntry =
|
|
entry.messageId === visibleReplyTargetEntry?.messageId ? visibleReplyTargetEntry : undefined;
|
|
const visibleEntry = {
|
|
...entry,
|
|
...selectedReplyEntry,
|
|
// Preserve authenticated identity while Telegram's selected quote body wins.
|
|
sender: entry.sender,
|
|
senderId: entry.senderId,
|
|
senderUsername: entry.senderUsername,
|
|
};
|
|
if (
|
|
!shouldIncludeGroupSupplementalContext({
|
|
kind: "quote",
|
|
senderId: visibleEntry.senderId,
|
|
senderUsername: visibleEntry.senderUsername,
|
|
})
|
|
) {
|
|
return [];
|
|
}
|
|
const includeForwarded =
|
|
visibleEntry.forwardedFrom &&
|
|
shouldIncludeGroupSupplementalContext({
|
|
kind: "forwarded",
|
|
senderId: visibleEntry.forwardedFromId,
|
|
senderUsername: visibleEntry.forwardedFromUsername,
|
|
});
|
|
return [includeForwarded ? visibleEntry : stripReplyChainForwarded(visibleEntry)];
|
|
});
|
|
const visibleForwardOrigin = includeForwardOrigin ? forwardOrigin : null;
|
|
const bufferedBodySegments = shouldRenderBufferedBody
|
|
? bufferedMessages.flatMap((bufferedMessage) => {
|
|
const bufferedMedia = resolveTelegramPrimaryMedia(bufferedMessage);
|
|
const textParts = getTelegramTextParts(bufferedMessage);
|
|
const segmentBody =
|
|
renderTelegramTextEntities(textParts.text, textParts.entities) ||
|
|
formatMediaPlaceholderText(bufferedMedia ? [{ kind: bufferedMedia.kind }] : []);
|
|
if (!segmentBody) {
|
|
return [];
|
|
}
|
|
const bufferedForwardOrigin = normalizeForwardedContext(bufferedMessage);
|
|
const visibleBufferedForwardOrigin =
|
|
bufferedForwardOrigin &&
|
|
shouldIncludeGroupSupplementalContext({
|
|
kind: "forwarded",
|
|
senderId: bufferedForwardOrigin.fromId,
|
|
senderUsername: bufferedForwardOrigin.fromUsername,
|
|
})
|
|
? bufferedForwardOrigin
|
|
: null;
|
|
return [
|
|
formatTelegramForwardedMessageBody({
|
|
body: segmentBody,
|
|
forwardedFrom: visibleBufferedForwardOrigin?.from,
|
|
forwardedDate: visibleBufferedForwardOrigin?.date
|
|
? visibleBufferedForwardOrigin.date * 1000
|
|
: undefined,
|
|
}),
|
|
];
|
|
})
|
|
: undefined;
|
|
const visibleBodyText = bufferedBodySegments?.length
|
|
? bufferedBodySegments.join("\n")
|
|
: formatTelegramForwardedMessageBody({
|
|
body: bodyText,
|
|
forwardedFrom: visibleForwardOrigin?.from,
|
|
forwardedDate: visibleForwardOrigin?.date ? visibleForwardOrigin.date * 1000 : undefined,
|
|
});
|
|
const replySuffix =
|
|
visibleReplyChain.length > 0
|
|
? `\n\n[Reply chain - nearest first]\n${visibleReplyChain
|
|
.map(formatReplyChainEntry)
|
|
.join("\n")}\n[/Reply chain]`
|
|
: "";
|
|
const groupLabel = isGroup ? buildGroupLabel(msg, chatId, resolvedThreadId) : undefined;
|
|
const senderName = buildSenderName(msg);
|
|
const conversationLabel = isGroup
|
|
? (groupLabel ?? `group:${chatId}`)
|
|
: buildSenderLabel(msg, senderId || chatId);
|
|
const sessionRuntime = await loadTelegramMessageContextSessionRuntime(sessionRuntimeOverride);
|
|
const storePath = await resolveTelegramMessageContextStorePath({
|
|
cfg,
|
|
agentId: route.agentId,
|
|
sessionRuntime: sessionRuntimeOverride,
|
|
});
|
|
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
|
const previousTimestamp = sessionRuntime.readSessionUpdatedAt({
|
|
storePath,
|
|
sessionKey: route.sessionKey,
|
|
});
|
|
const ambientTranscriptWatermarkKey =
|
|
isGroup && historyKey
|
|
? sessionRuntime.resolveAmbientTranscriptWatermarkKey({
|
|
channel: "telegram",
|
|
accountId: route.accountId,
|
|
conversationId: String(chatId),
|
|
...(resolvedThreadId !== undefined ? { threadId: resolvedThreadId } : {}),
|
|
})
|
|
: undefined;
|
|
const ambientTranscriptWatermark = ambientTranscriptWatermarkKey
|
|
? sessionRuntime.readAmbientTranscriptWatermark({
|
|
storePath,
|
|
sessionKey: route.sessionKey,
|
|
key: ambientTranscriptWatermarkKey,
|
|
})
|
|
: undefined;
|
|
const shouldSuppressPersistedDmChatWindowContext =
|
|
!isGroup &&
|
|
previousTimestamp !== undefined &&
|
|
dmThreadId == null &&
|
|
visibleReplyChain.length === 0 &&
|
|
!visibleReplyTarget;
|
|
// Existing plain DMs already carry their history through the persistent
|
|
// transcript. Keep chat windows for fresh DMs, topics, replies, and groups.
|
|
const baseVisiblePromptContext = shouldSuppressPersistedDmChatWindowContext
|
|
? promptContext.filter((entry) => !isTelegramChatWindowPromptContext(entry))
|
|
: promptContext;
|
|
const body = formatInboundEnvelope({
|
|
channel: "Telegram",
|
|
from: conversationLabel,
|
|
timestamp: msg.date ? msg.date * 1000 : undefined,
|
|
body: `${visibleBodyText}${replySuffix}`,
|
|
chatType: isGroup ? "group" : "direct",
|
|
sender: {
|
|
name: senderName,
|
|
username: senderUsername || undefined,
|
|
id: senderId || undefined,
|
|
},
|
|
previousTimestamp,
|
|
envelope: envelopeOptions,
|
|
});
|
|
const hasGroupHistoryContext = isGroup;
|
|
const commandBody = normalizeCommandBody(rawBody, {
|
|
botUsername: normalizeOptionalLowercaseString(primaryCtx.me?.username),
|
|
});
|
|
const commandSource =
|
|
options?.commandSource ??
|
|
(commandAuthorized && hasControlCommand ? ("text" as const) : undefined);
|
|
const conversationKind = isGroup ? "group" : "direct";
|
|
let watermarkedGroupHistoryEntries: HistoryEntry[] | undefined;
|
|
let groupHistoryPromptEntries: HistoryEntry[] = [];
|
|
if (hasGroupHistoryContext && historyKey && historyLimit > 0) {
|
|
const bufferedHistoryCount = groupHistories.get(historyKey)?.length ?? 0;
|
|
const fullGroupHistoryEntries = (
|
|
createChannelHistoryWindow({ historyMap: groupHistories }).buildInboundHistory({
|
|
historyKey,
|
|
limit: bufferedHistoryCount,
|
|
}) ?? []
|
|
)
|
|
.filter((entry) =>
|
|
isTelegramHistoryEntryAfterAmbientWatermark(entry, ambientTranscriptWatermark),
|
|
)
|
|
.slice(-historyLimit);
|
|
watermarkedGroupHistoryEntries =
|
|
selectTelegramGroupHistoryAfterLastSelf(fullGroupHistoryEntries).slice(-historyLimit);
|
|
groupHistoryPromptEntries =
|
|
inboundEventKind === "room_event" ? fullGroupHistoryEntries : watermarkedGroupHistoryEntries;
|
|
}
|
|
const retainedVisiblePromptContext = hasGroupHistoryContext
|
|
? retainTelegramGroupHistoryPromptContext({
|
|
promptContext: baseVisiblePromptContext,
|
|
entries: groupHistoryPromptEntries,
|
|
})
|
|
: baseVisiblePromptContext;
|
|
const visiblePromptContext = mergeTelegramGroupHistoryPromptContext({
|
|
promptContext: retainedVisiblePromptContext,
|
|
entries: groupHistoryPromptEntries,
|
|
});
|
|
|
|
const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({
|
|
groupConfig,
|
|
topicConfig,
|
|
});
|
|
const replyHead = visibleReplyChain[0];
|
|
const toInboundMedia = (media: TelegramMediaRef, index?: number) => ({
|
|
...(media.path ? { path: media.path, url: media.path } : {}),
|
|
contentType: media.contentType,
|
|
...(media.fileName ? { fileName: media.fileName } : {}),
|
|
kind: media.kind,
|
|
transcribed: index !== undefined && audioTranscribedMediaIndex === index,
|
|
});
|
|
const currentMediaFacts = allMedia.map(toInboundMedia);
|
|
const toReplyChainMediaFact = (entry: TelegramReplyChainEntry) =>
|
|
entry.mediaPath || entry.mediaKind || entry.mediaType
|
|
? {
|
|
...(entry.mediaPath ? { path: entry.mediaPath, url: entry.mediaPath } : {}),
|
|
...(entry.mediaKind ? { kind: entry.mediaKind } : {}),
|
|
...(entry.mediaType
|
|
? isTelegramMediaKind(entry.mediaType)
|
|
? entry.mediaKind
|
|
? {}
|
|
: { kind: entry.mediaType }
|
|
: { contentType: entry.mediaType }
|
|
: {}),
|
|
}
|
|
: undefined;
|
|
const replyMediaFacts =
|
|
visibleReplyChain.length > 0
|
|
? visibleReplyChain.flatMap((entry) => {
|
|
const media = toReplyChainMediaFact(entry);
|
|
return media ? [media] : [];
|
|
})
|
|
: visibleReplyTarget
|
|
? replyMedia.length > 0
|
|
? replyMedia.map((media) => toInboundMedia(media))
|
|
: visibleReplyTarget.mediaType
|
|
? [{ kind: visibleReplyTarget.mediaType }]
|
|
: []
|
|
: [];
|
|
const replyHeadMedia = replyHead ? toReplyChainMediaFact(replyHead) : undefined;
|
|
const replyTargetMedia =
|
|
replyHeadMedia ??
|
|
(visibleReplyTarget?.mediaType ? { kind: visibleReplyTarget.mediaType } : undefined);
|
|
const replyBody =
|
|
replyHead?.body ??
|
|
visibleReplyTarget?.body ??
|
|
(replyTargetMedia ? formatMediaPlaceholderText([replyTargetMedia]) : undefined);
|
|
const telegramFrom = isGroup ? buildTelegramGroupFrom(chatId, threadSpec) : `telegram:${chatId}`;
|
|
const telegramTo = buildTelegramInboundOriginTarget(chatId, threadSpec);
|
|
const locationContext = locationData ? toLocationContext(locationData) : undefined;
|
|
const telegramUpdate = primaryCtx.update;
|
|
const providerUpdateKind = telegramUpdate
|
|
? "edited_message" in telegramUpdate
|
|
? "edited_message"
|
|
: "message" in telegramUpdate
|
|
? "message"
|
|
: "edited_channel_post" in telegramUpdate
|
|
? "edited_channel_post"
|
|
: "channel_post" in telegramUpdate
|
|
? "channel_post"
|
|
: undefined
|
|
: undefined;
|
|
const inboundHistory =
|
|
hasGroupHistoryContext && historyKey && historyLimit > 0
|
|
? groupHistoryPromptEntries.length > 0
|
|
? groupHistoryPromptEntries
|
|
: undefined
|
|
: undefined;
|
|
const messageId = options?.messageIdOverride ?? String(msg.message_id);
|
|
const ingressContextBinding = Object.freeze({
|
|
agentId: route.agentId,
|
|
sessionKey: route.sessionKey,
|
|
messageId,
|
|
inboundEventKind,
|
|
});
|
|
const channelIngress = options?.channelIngressResolvers
|
|
? await Promise.all(
|
|
options.channelIngressResolvers.map((resolveChannelIngress) =>
|
|
resolveChannelIngress(ingressContextBinding),
|
|
),
|
|
)
|
|
: undefined;
|
|
const ctxPayload = await sessionRuntime.buildChannelInboundEventContext({
|
|
channel: "telegram",
|
|
channelIngress,
|
|
resolveSupplementalMedia: true,
|
|
accountId: route.accountId,
|
|
messageId,
|
|
timestamp: msg.date ? msg.date * 1000 : undefined,
|
|
from: telegramFrom,
|
|
sender: {
|
|
...(senderId ? { id: senderId } : {}),
|
|
name: senderName,
|
|
username: senderUsername || undefined,
|
|
isBot: msg.from?.is_bot,
|
|
},
|
|
conversation: {
|
|
kind: conversationKind,
|
|
id: String(chatId),
|
|
routePeer: {
|
|
kind: conversationKind,
|
|
id: isGroup
|
|
? buildTelegramConversationId({ chatId, thread: threadSpec })
|
|
: resolveTelegramDirectPeerId({ chatId, senderId }),
|
|
},
|
|
label: conversationLabel,
|
|
parentId: buildTelegramParentPeer({
|
|
isGroup,
|
|
resolvedThreadId: threadSpec.id,
|
|
chatId,
|
|
})?.id,
|
|
threadId: threadSpec.id != null ? String(threadSpec.id) : undefined,
|
|
},
|
|
route: {
|
|
agentId: route.agentId,
|
|
dmScope: route.dmScope,
|
|
accountId: route.accountId,
|
|
routeSessionKey: route.sessionKey,
|
|
mainSessionKey: route.mainSessionKey,
|
|
},
|
|
reply: {
|
|
to: telegramTo,
|
|
replyToId: replyHead?.messageId ?? visibleReplyTarget?.id,
|
|
messageThreadId: threadSpec.id,
|
|
},
|
|
message: {
|
|
inboundEventKind,
|
|
body,
|
|
rawBody,
|
|
bodyForAgent: shouldRenderBufferedBody ? visibleBodyText : bodyText,
|
|
commandBody,
|
|
inboundHistory,
|
|
sourceModality: msg.voice ? "voice" : undefined,
|
|
},
|
|
sessionTranscript: {
|
|
chatWindow: true,
|
|
historyLimit: isGroup ? historyLimit : dmHistoryLimit,
|
|
beforeTimestampMs: options?.receivedAtMs ?? (msg.date ? msg.date * 1000 : undefined),
|
|
minTimestampMs: options?.promptContextMinTimestampMs,
|
|
senderLabels: { assistant: "OpenClaw", user: "User" },
|
|
},
|
|
access: {
|
|
commands: {
|
|
authorized: commandAuthorized,
|
|
},
|
|
toolPolicy: isGroup
|
|
? undefined
|
|
: resolveTelegramDirectToolPolicy({
|
|
directConfig: groupConfig,
|
|
senderId,
|
|
senderName,
|
|
senderUsername,
|
|
}),
|
|
mentions: mentionFacts,
|
|
},
|
|
command:
|
|
commandSource === "native"
|
|
? {
|
|
kind: "native",
|
|
authorized: commandAuthorized,
|
|
body: commandBody,
|
|
}
|
|
: commandSource === "text"
|
|
? {
|
|
kind: "text-slash",
|
|
authorized: commandAuthorized,
|
|
body: commandBody,
|
|
}
|
|
: undefined,
|
|
media: currentMediaFacts,
|
|
supplemental: {
|
|
quote:
|
|
replyHead || visibleReplyTarget
|
|
? {
|
|
id: replyHead?.messageId ?? visibleReplyTarget?.id,
|
|
body: replyBody,
|
|
sender: replyHead?.sender ?? visibleReplyTarget?.sender,
|
|
senderAllowed: true,
|
|
isQuote:
|
|
replyHead?.isQuote ?? (visibleReplyTarget?.kind === "quote" ? true : undefined),
|
|
media: replyMediaFacts,
|
|
}
|
|
: undefined,
|
|
forwarded: visibleForwardOrigin
|
|
? {
|
|
from: visibleForwardOrigin.from,
|
|
fromType: visibleForwardOrigin.fromType,
|
|
fromId: visibleForwardOrigin.fromId,
|
|
date: visibleForwardOrigin.date ? visibleForwardOrigin.date * 1000 : undefined,
|
|
senderAllowed: true,
|
|
}
|
|
: undefined,
|
|
groupSystemPrompt: isGroup || (!isGroup && groupConfig) ? groupSystemPrompt : undefined,
|
|
channelStructuredContext: visiblePromptContext.length > 0 ? visiblePromptContext : undefined,
|
|
},
|
|
contextVisibility: contextVisibilityMode,
|
|
extra: {
|
|
BotUsername: primaryCtx.me?.username ?? undefined,
|
|
AmbientTranscriptWatermarkKey: ambientTranscriptWatermarkKey,
|
|
AmbientTranscriptBody: options?.ambientTranscriptBody,
|
|
AmbientTranscriptMessageId: ambientTranscriptWatermarkKey
|
|
? (options?.messageIdOverride ?? String(msg.message_id))
|
|
: undefined,
|
|
AmbientTranscriptTimestampMs: ambientTranscriptWatermarkKey
|
|
? msg.date
|
|
? msg.date * 1000
|
|
: undefined
|
|
: undefined,
|
|
AmbientTranscriptPreviousMessageId: ambientTranscriptWatermark?.messageId,
|
|
AmbientTranscriptPreviousTimestampMs: ambientTranscriptWatermark?.timestampMs,
|
|
GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined,
|
|
GroupRequireMention: isGroup ? groupRequireMention : undefined,
|
|
ReplyChain: visibleReplyChain.length > 0 ? visibleReplyChain : undefined,
|
|
ReplyToIsExternal: visibleReplyTarget?.source === "external_reply" ? true : undefined,
|
|
ReplyToQuoteText: visibleReplyTarget?.quoteText,
|
|
ReplyToQuotePosition: visibleReplyTarget?.quotePosition,
|
|
ReplyToQuoteEntities: visibleReplyTarget?.quoteEntities,
|
|
ReplyToQuoteSourceText: visibleReplyTarget?.quoteSourceText,
|
|
ReplyToQuoteSourceEntities: visibleReplyTarget?.quoteSourceEntities,
|
|
ReplyToForwardedFrom: visibleReplyTarget?.forwardedFrom?.from,
|
|
ReplyToForwardedFromType: visibleReplyTarget?.forwardedFrom?.fromType,
|
|
ReplyToForwardedFromId: visibleReplyTarget?.forwardedFrom?.fromId,
|
|
ReplyToForwardedFromUsername: visibleReplyTarget?.forwardedFrom?.fromUsername,
|
|
ReplyToForwardedFromTitle: visibleReplyTarget?.forwardedFrom?.fromTitle,
|
|
ReplyToForwardedDate: visibleReplyTarget?.forwardedFrom?.date
|
|
? visibleReplyTarget.forwardedFrom.date * 1000
|
|
: undefined,
|
|
ForwardedFromUsername: visibleForwardOrigin?.fromUsername,
|
|
ForwardedFromTitle: visibleForwardOrigin?.fromTitle,
|
|
ForwardedFromSignature: visibleForwardOrigin?.fromSignature,
|
|
ForwardedFromChatType: visibleForwardOrigin?.fromChatType,
|
|
ForwardedFromMessageId: visibleForwardOrigin?.fromMessageId,
|
|
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
|
|
Sticker: allMedia[0]?.stickerMetadata,
|
|
StickerMediaIncluded: allMedia[0]?.stickerMetadata ? currentMediaFacts.length > 0 : undefined,
|
|
SkipStickerMediaUnderstanding: stickerCacheHit ? true : undefined,
|
|
...locationContext,
|
|
ProviderUpdateId:
|
|
typeof telegramUpdate?.update_id === "number"
|
|
? String(telegramUpdate.update_id)
|
|
: undefined,
|
|
ProviderUpdateKind: providerUpdateKind,
|
|
ProviderMessageTimestamp: primaryCtx.message?.date
|
|
? primaryCtx.message.date * 1000
|
|
: undefined,
|
|
ProviderEditTimestamp: primaryCtx.message?.edit_date
|
|
? primaryCtx.message.edit_date * 1000
|
|
: undefined,
|
|
LocationLivePeriodSeconds: primaryCtx.message?.location?.live_period,
|
|
IsForum: isForum,
|
|
TopicName: isForum && topicName ? topicName : undefined,
|
|
},
|
|
} satisfies BuildChannelInboundEventContextAsyncParams);
|
|
if (isGroup && historyKey) {
|
|
recordTelegramGroupHistoryEntry({
|
|
historyMap: groupHistories,
|
|
historyKey,
|
|
limit: historyLimit,
|
|
entry: {
|
|
sender: buildSenderLabel(msg, senderId || chatId),
|
|
body:
|
|
rawBody ||
|
|
(stickerCacheHit ? bodyText : undefined) ||
|
|
formatMediaPlaceholderText(currentMediaFacts),
|
|
timestamp: msg.date ? msg.date * 1000 : undefined,
|
|
messageId: typeof msg.message_id === "number" ? String(msg.message_id) : undefined,
|
|
},
|
|
});
|
|
}
|
|
|
|
const pinnedMainDmOwner = !isGroup
|
|
? sessionRuntime.resolvePinnedMainDmOwnerFromAllowlist({
|
|
dmScope: cfg.session?.dmScope,
|
|
allowFrom: dmAllowFrom,
|
|
normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0],
|
|
})
|
|
: null;
|
|
const updateLastRouteSessionKey = sessionRuntime.resolveInboundLastRouteSessionKey({
|
|
route,
|
|
sessionKey: route.sessionKey,
|
|
});
|
|
const shouldPersistGroupLastRouteThread = isGroup && route.matchedBy !== "binding.channel";
|
|
const updateLastRouteThreadId = isGroup
|
|
? shouldPersistGroupLastRouteThread && resolvedThreadId != null
|
|
? String(resolvedThreadId)
|
|
: undefined
|
|
: dmThreadId != null
|
|
? String(dmThreadId)
|
|
: undefined;
|
|
|
|
const updateLastRoute =
|
|
!isGroup || updateLastRouteThreadId != null
|
|
? {
|
|
sessionKey: updateLastRouteSessionKey,
|
|
channel: "telegram" as const,
|
|
// Persist the same canonical target used by the live context. General topic
|
|
// stays chat-scoped while threadId keeps its conversation distinct.
|
|
to: telegramTo,
|
|
accountId: route.accountId,
|
|
threadId: updateLastRouteThreadId,
|
|
mainDmOwnerPin:
|
|
!isGroup &&
|
|
updateLastRouteSessionKey === route.mainSessionKey &&
|
|
pinnedMainDmOwner &&
|
|
senderId
|
|
? {
|
|
ownerRecipient: pinnedMainDmOwner,
|
|
senderRecipient: senderId,
|
|
onSkip: (skipParams: { ownerRecipient: string; senderRecipient: string }) => {
|
|
logVerbose(
|
|
`telegram: skip main-session last route for ${skipParams.senderRecipient} (pinned owner ${skipParams.ownerRecipient})`,
|
|
);
|
|
},
|
|
}
|
|
: undefined,
|
|
}
|
|
: undefined;
|
|
|
|
if (visibleReplyTarget && shouldLogVerbose()) {
|
|
const preview = truncateUtf16Safe((visibleReplyTarget.body ?? "").replace(/\s+/g, " "), 120);
|
|
logVerbose(
|
|
`telegram reply-context: replyToId=${visibleReplyTarget.id} replyToSender=${visibleReplyTarget.sender} replyToBody="${preview}"`,
|
|
);
|
|
}
|
|
|
|
if (visibleForwardOrigin && shouldLogVerbose()) {
|
|
logVerbose(
|
|
`telegram forward-context: forwardedFrom="${visibleForwardOrigin.from}" type=${visibleForwardOrigin.fromType}`,
|
|
);
|
|
}
|
|
|
|
if (shouldLogVerbose()) {
|
|
const preview = truncateUtf16Safe(body, 200).replace(/\n/g, "\\n");
|
|
const mediaInfo = allMedia.length > 1 ? ` mediaCount=${allMedia.length}` : "";
|
|
const topicInfo = resolvedThreadId != null ? ` topic=${resolvedThreadId}` : "";
|
|
logVerbose(
|
|
`telegram inbound: chatId=${chatId} from=${ctxPayload.From} len=${body.length}${mediaInfo}${topicInfo} preview="${preview}"`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
ctxPayload,
|
|
skillFilter,
|
|
turn: {
|
|
storePath,
|
|
recordInboundSession: sessionRuntime.recordInboundSession,
|
|
record: {
|
|
updateLastRoute,
|
|
onRecordError: (err) => {
|
|
logVerbose(`telegram: failed updating session meta: ${String(err)}`);
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|