mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor(telegram): split bot message context helpers
This commit is contained in:
284
src/telegram/bot-message-context.body.ts
Normal file
284
src/telegram/bot-message-context.body.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import {
|
||||
findModelInCatalog,
|
||||
loadModelCatalog,
|
||||
modelSupportsVision,
|
||||
} from "../agents/model-catalog.js";
|
||||
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
|
||||
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||
import {
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
type HistoryEntry,
|
||||
} from "../auto-reply/reply/history.js";
|
||||
import { buildMentionRegexes, matchesMentionWithExplicit } from "../auto-reply/reply/mentions.js";
|
||||
import type { MsgContext } from "../auto-reply/templating.js";
|
||||
import { resolveControlCommandGate } from "../channels/command-gating.js";
|
||||
import { formatLocationText, type NormalizedLocation } from "../channels/location.js";
|
||||
import { logInboundDrop } from "../channels/logging.js";
|
||||
import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type {
|
||||
TelegramDirectConfig,
|
||||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} from "../config/types.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import type { NormalizedAllowFrom } from "./bot-access.js";
|
||||
import { isSenderAllowed } from "./bot-access.js";
|
||||
import type {
|
||||
TelegramLogger,
|
||||
TelegramMediaRef,
|
||||
TelegramMessageContextOptions,
|
||||
} from "./bot-message-context.types.js";
|
||||
import {
|
||||
buildSenderLabel,
|
||||
buildTelegramGroupPeerId,
|
||||
expandTextLinks,
|
||||
extractTelegramLocation,
|
||||
getTelegramTextParts,
|
||||
hasBotMention,
|
||||
resolveTelegramMediaPlaceholder,
|
||||
} from "./bot/helpers.js";
|
||||
import type { TelegramContext } from "./bot/types.js";
|
||||
import { isTelegramForumServiceMessage } from "./forum-service-message.js";
|
||||
|
||||
export type TelegramInboundBodyResult = {
|
||||
bodyText: string;
|
||||
rawBody: string;
|
||||
historyKey?: string;
|
||||
commandAuthorized: boolean;
|
||||
effectiveWasMentioned: boolean;
|
||||
canDetectMention: boolean;
|
||||
shouldBypassMention: boolean;
|
||||
stickerCacheHit: boolean;
|
||||
locationData?: NormalizedLocation;
|
||||
};
|
||||
|
||||
async function resolveStickerVisionSupport(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId?: string;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const catalog = await loadModelCatalog({ config: params.cfg });
|
||||
const defaultModel = resolveDefaultModelForAgent({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
return modelSupportsVision(entry);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveTelegramInboundBody(params: {
|
||||
cfg: OpenClawConfig;
|
||||
primaryCtx: TelegramContext;
|
||||
msg: TelegramContext["message"];
|
||||
allMedia: TelegramMediaRef[];
|
||||
isGroup: boolean;
|
||||
chatId: number | string;
|
||||
senderId: string;
|
||||
senderUsername: string;
|
||||
resolvedThreadId?: number;
|
||||
routeAgentId?: string;
|
||||
effectiveGroupAllow: NormalizedAllowFrom;
|
||||
effectiveDmAllow: NormalizedAllowFrom;
|
||||
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
requireMention?: boolean;
|
||||
options?: TelegramMessageContextOptions;
|
||||
groupHistories: Map<string, HistoryEntry[]>;
|
||||
historyLimit: number;
|
||||
logger: TelegramLogger;
|
||||
}): Promise<TelegramInboundBodyResult | null> {
|
||||
const {
|
||||
cfg,
|
||||
primaryCtx,
|
||||
msg,
|
||||
allMedia,
|
||||
isGroup,
|
||||
chatId,
|
||||
senderId,
|
||||
senderUsername,
|
||||
resolvedThreadId,
|
||||
routeAgentId,
|
||||
effectiveGroupAllow,
|
||||
effectiveDmAllow,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
requireMention,
|
||||
options,
|
||||
groupHistories,
|
||||
historyLimit,
|
||||
logger,
|
||||
} = params;
|
||||
const botUsername = primaryCtx.me?.username?.toLowerCase();
|
||||
const mentionRegexes = buildMentionRegexes(cfg, routeAgentId);
|
||||
const messageTextParts = getTelegramTextParts(msg);
|
||||
const allowForCommands = isGroup ? effectiveGroupAllow : effectiveDmAllow;
|
||||
const senderAllowedForCommands = isSenderAllowed({
|
||||
allow: allowForCommands,
|
||||
senderId,
|
||||
senderUsername,
|
||||
});
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const hasControlCommandInMessage = hasControlCommand(messageTextParts.text, cfg, {
|
||||
botUsername,
|
||||
});
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
});
|
||||
const commandAuthorized = commandGate.commandAuthorized;
|
||||
const historyKey = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : undefined;
|
||||
|
||||
let placeholder = resolveTelegramMediaPlaceholder(msg) ?? "";
|
||||
const cachedStickerDescription = allMedia[0]?.stickerMetadata?.cachedDescription;
|
||||
const stickerSupportsVision = msg.sticker
|
||||
? await resolveStickerVisionSupport({ cfg, agentId: routeAgentId })
|
||||
: false;
|
||||
const stickerCacheHit = Boolean(cachedStickerDescription) && !stickerSupportsVision;
|
||||
if (stickerCacheHit) {
|
||||
const emoji = allMedia[0]?.stickerMetadata?.emoji;
|
||||
const setName = allMedia[0]?.stickerMetadata?.setName;
|
||||
const stickerContext = [emoji, setName ? `from "${setName}"` : null].filter(Boolean).join(" ");
|
||||
placeholder = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${cachedStickerDescription}`;
|
||||
}
|
||||
|
||||
const locationData = extractTelegramLocation(msg);
|
||||
const locationText = locationData ? formatLocationText(locationData) : undefined;
|
||||
const rawText = expandTextLinks(messageTextParts.text, messageTextParts.entities).trim();
|
||||
const hasUserText = Boolean(rawText || locationText);
|
||||
let rawBody = [rawText, locationText].filter(Boolean).join("\n").trim();
|
||||
if (!rawBody) {
|
||||
rawBody = placeholder;
|
||||
}
|
||||
if (!rawBody && allMedia.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let bodyText = rawBody;
|
||||
const hasAudio = allMedia.some((media) => media.contentType?.startsWith("audio/"));
|
||||
const disableAudioPreflight =
|
||||
(topicConfig?.disableAudioPreflight ??
|
||||
(groupConfig as TelegramGroupConfig | undefined)?.disableAudioPreflight) === true;
|
||||
|
||||
let preflightTranscript: string | undefined;
|
||||
const needsPreflightTranscription =
|
||||
isGroup &&
|
||||
requireMention &&
|
||||
hasAudio &&
|
||||
!hasUserText &&
|
||||
mentionRegexes.length > 0 &&
|
||||
!disableAudioPreflight;
|
||||
|
||||
if (needsPreflightTranscription) {
|
||||
try {
|
||||
const { transcribeFirstAudio } = await import("../media-understanding/audio-preflight.js");
|
||||
const tempCtx: MsgContext = {
|
||||
MediaPaths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
|
||||
MediaTypes:
|
||||
allMedia.length > 0
|
||||
? (allMedia.map((m) => m.contentType).filter(Boolean) as string[])
|
||||
: undefined,
|
||||
};
|
||||
preflightTranscript = await transcribeFirstAudio({
|
||||
ctx: tempCtx,
|
||||
cfg,
|
||||
agentDir: undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(`telegram: audio preflight transcription failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAudio && bodyText === "<media:audio>" && preflightTranscript) {
|
||||
bodyText = preflightTranscript;
|
||||
}
|
||||
|
||||
if (!bodyText && allMedia.length > 0) {
|
||||
if (hasAudio) {
|
||||
bodyText = preflightTranscript || "<media:audio>";
|
||||
} else {
|
||||
bodyText = `<media:image>${allMedia.length > 1 ? ` (${allMedia.length} images)` : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
const hasAnyMention = messageTextParts.entities.some((ent) => ent.type === "mention");
|
||||
const explicitlyMentioned = botUsername ? hasBotMention(msg, botUsername) : false;
|
||||
const computedWasMentioned = matchesMentionWithExplicit({
|
||||
text: messageTextParts.text,
|
||||
mentionRegexes,
|
||||
explicit: {
|
||||
hasAnyMention,
|
||||
isExplicitlyMentioned: explicitlyMentioned,
|
||||
canResolveExplicit: Boolean(botUsername),
|
||||
},
|
||||
transcript: preflightTranscript,
|
||||
});
|
||||
const wasMentioned = options?.forceWasMentioned === true ? true : computedWasMentioned;
|
||||
|
||||
if (isGroup && commandGate.shouldBlock) {
|
||||
logInboundDrop({
|
||||
log: logVerbose,
|
||||
channel: "telegram",
|
||||
reason: "control command (unauthorized)",
|
||||
target: senderId ?? "unknown",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const botId = primaryCtx.me?.id;
|
||||
const replyFromId = msg.reply_to_message?.from?.id;
|
||||
const replyToBotMessage = botId != null && replyFromId === botId;
|
||||
const isReplyToServiceMessage =
|
||||
replyToBotMessage && isTelegramForumServiceMessage(msg.reply_to_message);
|
||||
const implicitMention = replyToBotMessage && !isReplyToServiceMessage;
|
||||
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
|
||||
const mentionGate = resolveMentionGatingWithBypass({
|
||||
isGroup,
|
||||
requireMention: Boolean(requireMention),
|
||||
canDetectMention,
|
||||
wasMentioned,
|
||||
implicitMention: isGroup && Boolean(requireMention) && implicitMention,
|
||||
hasAnyMention,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
commandAuthorized,
|
||||
});
|
||||
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
|
||||
if (isGroup && requireMention && canDetectMention && mentionGate.shouldSkip) {
|
||||
logger.info({ chatId, reason: "no-mention" }, "skipping group message");
|
||||
recordPendingHistoryEntryIfEnabled({
|
||||
historyMap: groupHistories,
|
||||
historyKey: historyKey ?? "",
|
||||
limit: historyLimit,
|
||||
entry: historyKey
|
||||
? {
|
||||
sender: buildSenderLabel(msg, senderId || chatId),
|
||||
body: rawBody,
|
||||
timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||
messageId: typeof msg.message_id === "number" ? String(msg.message_id) : undefined,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
bodyText,
|
||||
rawBody,
|
||||
historyKey,
|
||||
commandAuthorized,
|
||||
effectiveWasMentioned,
|
||||
canDetectMention,
|
||||
shouldBypassMention: mentionGate.shouldBypassMention,
|
||||
stickerCacheHit,
|
||||
locationData: locationData ?? undefined,
|
||||
};
|
||||
}
|
||||
316
src/telegram/bot-message-context.session.ts
Normal file
316
src/telegram/bot-message-context.session.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { normalizeCommandBody } from "../auto-reply/commands-registry.js";
|
||||
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js";
|
||||
import {
|
||||
buildPendingHistoryContextFromMap,
|
||||
type HistoryEntry,
|
||||
} from "../auto-reply/reply/history.js";
|
||||
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
|
||||
import { toLocationContext } from "../channels/location.js";
|
||||
import { recordInboundSession } from "../channels/session.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { readSessionUpdatedAt, resolveStorePath } from "../config/sessions.js";
|
||||
import type {
|
||||
TelegramDirectConfig,
|
||||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} from "../config/types.js";
|
||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||
import type { ResolvedAgentRoute } from "../routing/resolve-route.js";
|
||||
import { resolveInboundLastRouteSessionKey } from "../routing/resolve-route.js";
|
||||
import { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js";
|
||||
import { normalizeAllowFrom } from "./bot-access.js";
|
||||
import type {
|
||||
TelegramMediaRef,
|
||||
TelegramMessageContextOptions,
|
||||
} from "./bot-message-context.types.js";
|
||||
import {
|
||||
buildGroupLabel,
|
||||
buildSenderLabel,
|
||||
buildSenderName,
|
||||
buildTelegramGroupFrom,
|
||||
describeReplyTarget,
|
||||
normalizeForwardedContext,
|
||||
type TelegramThreadSpec,
|
||||
} from "./bot/helpers.js";
|
||||
import type { TelegramContext } from "./bot/types.js";
|
||||
import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js";
|
||||
|
||||
export async function buildTelegramInboundContextPayload(params: {
|
||||
cfg: OpenClawConfig;
|
||||
primaryCtx: TelegramContext;
|
||||
msg: TelegramContext["message"];
|
||||
allMedia: TelegramMediaRef[];
|
||||
replyMedia: TelegramMediaRef[];
|
||||
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;
|
||||
groupHistories: Map<string, HistoryEntry[]>;
|
||||
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
stickerCacheHit: boolean;
|
||||
effectiveWasMentioned: boolean;
|
||||
commandAuthorized: boolean;
|
||||
locationData?: import("../channels/location.js").NormalizedLocation;
|
||||
options?: TelegramMessageContextOptions;
|
||||
dmAllowFrom?: Array<string | number>;
|
||||
}): Promise<{
|
||||
ctxPayload: ReturnType<typeof finalizeInboundContext>;
|
||||
skillFilter: string[] | undefined;
|
||||
}> {
|
||||
const {
|
||||
cfg,
|
||||
primaryCtx,
|
||||
msg,
|
||||
allMedia,
|
||||
replyMedia,
|
||||
isGroup,
|
||||
isForum,
|
||||
chatId,
|
||||
senderId,
|
||||
senderUsername,
|
||||
resolvedThreadId,
|
||||
dmThreadId,
|
||||
threadSpec,
|
||||
route,
|
||||
rawBody,
|
||||
bodyText,
|
||||
historyKey,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
stickerCacheHit,
|
||||
effectiveWasMentioned,
|
||||
commandAuthorized,
|
||||
locationData,
|
||||
options,
|
||||
dmAllowFrom,
|
||||
} = params;
|
||||
const replyTarget = describeReplyTarget(msg);
|
||||
const forwardOrigin = normalizeForwardedContext(msg);
|
||||
const replyForwardAnnotation = replyTarget?.forwardedFrom
|
||||
? `[Forwarded from ${replyTarget.forwardedFrom.from}${
|
||||
replyTarget.forwardedFrom.date
|
||||
? ` at ${new Date(replyTarget.forwardedFrom.date * 1000).toISOString()}`
|
||||
: ""
|
||||
}]\n`
|
||||
: "";
|
||||
const replySuffix = replyTarget
|
||||
? replyTarget.kind === "quote"
|
||||
? `\n\n[Quoting ${replyTarget.sender}${
|
||||
replyTarget.id ? ` id:${replyTarget.id}` : ""
|
||||
}]\n${replyForwardAnnotation}"${replyTarget.body}"\n[/Quoting]`
|
||||
: `\n\n[Replying to ${replyTarget.sender}${
|
||||
replyTarget.id ? ` id:${replyTarget.id}` : ""
|
||||
}]\n${replyForwardAnnotation}${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
|
||||
? (groupLabel ?? `group:${chatId}`)
|
||||
: buildSenderLabel(msg, senderId || chatId);
|
||||
const storePath = resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
||||
const previousTimestamp = readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = formatInboundEnvelope({
|
||||
channel: "Telegram",
|
||||
from: conversationLabel,
|
||||
timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||
body: `${forwardPrefix}${bodyText}${replySuffix}`,
|
||||
chatType: isGroup ? "group" : "direct",
|
||||
sender: {
|
||||
name: senderName,
|
||||
username: senderUsername || undefined,
|
||||
id: senderId || undefined,
|
||||
},
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
});
|
||||
let combinedBody = body;
|
||||
if (isGroup && historyKey && historyLimit > 0) {
|
||||
combinedBody = buildPendingHistoryContextFromMap({
|
||||
historyMap: groupHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (entry) =>
|
||||
formatInboundEnvelope({
|
||||
channel: "Telegram",
|
||||
from: groupLabel ?? `group:${chatId}`,
|
||||
timestamp: entry.timestamp,
|
||||
body: `${entry.body} [id:${entry.messageId ?? "unknown"} chat:${chatId}]`,
|
||||
chatType: "group",
|
||||
senderLabel: entry.sender,
|
||||
envelope: envelopeOptions,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
});
|
||||
const commandBody = normalizeCommandBody(rawBody, {
|
||||
botUsername: primaryCtx.me?.username?.toLowerCase(),
|
||||
});
|
||||
const inboundHistory =
|
||||
isGroup && historyKey && historyLimit > 0
|
||||
? (groupHistories.get(historyKey) ?? []).map((entry) => ({
|
||||
sender: entry.sender,
|
||||
body: entry.body,
|
||||
timestamp: entry.timestamp,
|
||||
}))
|
||||
: undefined;
|
||||
const currentMediaForContext = stickerCacheHit ? [] : allMedia;
|
||||
const contextMedia = [...currentMediaForContext, ...replyMedia];
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
BodyForAgent: bodyText,
|
||||
InboundHistory: inboundHistory,
|
||||
RawBody: rawBody,
|
||||
CommandBody: commandBody,
|
||||
From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`,
|
||||
To: `telegram:${chatId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
ConversationLabel: conversationLabel,
|
||||
GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined,
|
||||
GroupSystemPrompt: isGroup || (!isGroup && groupConfig) ? groupSystemPrompt : undefined,
|
||||
SenderName: senderName,
|
||||
SenderId: senderId || undefined,
|
||||
SenderUsername: senderUsername || undefined,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
MessageSid: options?.messageIdOverride ?? String(msg.message_id),
|
||||
ReplyToId: replyTarget?.id,
|
||||
ReplyToBody: replyTarget?.body,
|
||||
ReplyToSender: replyTarget?.sender,
|
||||
ReplyToIsQuote: replyTarget?.kind === "quote" ? true : undefined,
|
||||
ReplyToForwardedFrom: replyTarget?.forwardedFrom?.from,
|
||||
ReplyToForwardedFromType: replyTarget?.forwardedFrom?.fromType,
|
||||
ReplyToForwardedFromId: replyTarget?.forwardedFrom?.fromId,
|
||||
ReplyToForwardedFromUsername: replyTarget?.forwardedFrom?.fromUsername,
|
||||
ReplyToForwardedFromTitle: replyTarget?.forwardedFrom?.fromTitle,
|
||||
ReplyToForwardedDate: replyTarget?.forwardedFrom?.date
|
||||
? replyTarget.forwardedFrom.date * 1000
|
||||
: undefined,
|
||||
ForwardedFrom: forwardOrigin?.from,
|
||||
ForwardedFromType: forwardOrigin?.fromType,
|
||||
ForwardedFromId: forwardOrigin?.fromId,
|
||||
ForwardedFromUsername: forwardOrigin?.fromUsername,
|
||||
ForwardedFromTitle: forwardOrigin?.fromTitle,
|
||||
ForwardedFromSignature: forwardOrigin?.fromSignature,
|
||||
ForwardedFromChatType: forwardOrigin?.fromChatType,
|
||||
ForwardedFromMessageId: forwardOrigin?.fromMessageId,
|
||||
ForwardedDate: forwardOrigin?.date ? forwardOrigin.date * 1000 : undefined,
|
||||
Timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
|
||||
MediaPath: contextMedia.length > 0 ? contextMedia[0]?.path : undefined,
|
||||
MediaType: contextMedia.length > 0 ? contextMedia[0]?.contentType : undefined,
|
||||
MediaUrl: contextMedia.length > 0 ? contextMedia[0]?.path : undefined,
|
||||
MediaPaths: contextMedia.length > 0 ? contextMedia.map((m) => m.path) : undefined,
|
||||
MediaUrls: contextMedia.length > 0 ? contextMedia.map((m) => m.path) : undefined,
|
||||
MediaTypes:
|
||||
contextMedia.length > 0
|
||||
? (contextMedia.map((m) => m.contentType).filter(Boolean) as string[])
|
||||
: undefined,
|
||||
Sticker: allMedia[0]?.stickerMetadata,
|
||||
StickerMediaIncluded: allMedia[0]?.stickerMetadata ? !stickerCacheHit : undefined,
|
||||
...(locationData ? toLocationContext(locationData) : undefined),
|
||||
CommandAuthorized: commandAuthorized,
|
||||
MessageThreadId: threadSpec.id,
|
||||
IsForum: isForum,
|
||||
OriginatingChannel: "telegram" as const,
|
||||
OriginatingTo: `telegram:${chatId}`,
|
||||
});
|
||||
|
||||
const pinnedMainDmOwner = !isGroup
|
||||
? resolvePinnedMainDmOwnerFromAllowlist({
|
||||
dmScope: cfg.session?.dmScope,
|
||||
allowFrom: dmAllowFrom,
|
||||
normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0],
|
||||
})
|
||||
: null;
|
||||
const updateLastRouteSessionKey = resolveInboundLastRouteSessionKey({
|
||||
route,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
|
||||
await recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
updateLastRoute: !isGroup
|
||||
? {
|
||||
sessionKey: updateLastRouteSessionKey,
|
||||
channel: "telegram",
|
||||
to: `telegram:${chatId}`,
|
||||
accountId: route.accountId,
|
||||
threadId: dmThreadId != null ? String(dmThreadId) : undefined,
|
||||
mainDmOwnerPin:
|
||||
updateLastRouteSessionKey === route.mainSessionKey && pinnedMainDmOwner && senderId
|
||||
? {
|
||||
ownerRecipient: pinnedMainDmOwner,
|
||||
senderRecipient: senderId,
|
||||
onSkip: ({ ownerRecipient, senderRecipient }) => {
|
||||
logVerbose(
|
||||
`telegram: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
|
||||
);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
onRecordError: (err) => {
|
||||
logVerbose(`telegram: failed updating session meta: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
if (replyTarget && shouldLogVerbose()) {
|
||||
const preview = replyTarget.body.replace(/\s+/g, " ").slice(0, 120);
|
||||
logVerbose(
|
||||
`telegram reply-context: replyToId=${replyTarget.id} replyToSender=${replyTarget.sender} replyToBody="${preview}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (forwardOrigin && shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`telegram forward-context: forwardedFrom="${forwardOrigin.from}" type=${forwardOrigin.fromType}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
const preview = body.slice(0, 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,
|
||||
};
|
||||
}
|
||||
@@ -1,81 +1,30 @@
|
||||
import type { Bot } from "grammy";
|
||||
import { ensureConfiguredAcpRouteReady } from "../acp/persistent-bindings.route.js";
|
||||
import { resolveAckReaction } from "../agents/identity.js";
|
||||
import {
|
||||
findModelInCatalog,
|
||||
loadModelCatalog,
|
||||
modelSupportsVision,
|
||||
} from "../agents/model-catalog.js";
|
||||
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
|
||||
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||
import { normalizeCommandBody } from "../auto-reply/commands-registry.js";
|
||||
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js";
|
||||
import {
|
||||
buildPendingHistoryContextFromMap,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
type HistoryEntry,
|
||||
} from "../auto-reply/reply/history.js";
|
||||
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
|
||||
import { buildMentionRegexes, matchesMentionWithExplicit } from "../auto-reply/reply/mentions.js";
|
||||
import type { MsgContext } from "../auto-reply/templating.js";
|
||||
import { shouldAckReaction as shouldAckReactionGate } from "../channels/ack-reactions.js";
|
||||
import { resolveControlCommandGate } from "../channels/command-gating.js";
|
||||
import { formatLocationText, toLocationContext } from "../channels/location.js";
|
||||
import { logInboundDrop } from "../channels/logging.js";
|
||||
import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js";
|
||||
import { recordInboundSession } from "../channels/session.js";
|
||||
import {
|
||||
createStatusReactionController,
|
||||
type StatusReactionController,
|
||||
} from "../channels/status-reactions.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { readSessionUpdatedAt, resolveStorePath } from "../config/sessions.js";
|
||||
import type {
|
||||
DmPolicy,
|
||||
TelegramDirectConfig,
|
||||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} from "../config/types.js";
|
||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||
import type { TelegramDirectConfig, TelegramGroupConfig } from "../config/types.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||
import {
|
||||
buildAgentSessionKey,
|
||||
deriveLastRoutePolicy,
|
||||
resolveInboundLastRouteSessionKey,
|
||||
} from "../routing/resolve-route.js";
|
||||
import { buildAgentSessionKey, deriveLastRoutePolicy } from "../routing/resolve-route.js";
|
||||
import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "../routing/session-key.js";
|
||||
import { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js";
|
||||
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
||||
import { firstDefined, normalizeAllowFrom, normalizeDmAllowFromWithStore } from "./bot-access.js";
|
||||
import { resolveTelegramInboundBody } from "./bot-message-context.body.js";
|
||||
import { buildTelegramInboundContextPayload } from "./bot-message-context.session.js";
|
||||
import type { BuildTelegramMessageContextParams } from "./bot-message-context.types.js";
|
||||
import {
|
||||
firstDefined,
|
||||
isSenderAllowed,
|
||||
normalizeAllowFrom,
|
||||
normalizeDmAllowFromWithStore,
|
||||
} from "./bot-access.js";
|
||||
import {
|
||||
buildGroupLabel,
|
||||
buildSenderLabel,
|
||||
buildSenderName,
|
||||
buildTelegramGroupFrom,
|
||||
buildTelegramGroupPeerId,
|
||||
buildTypingThreadParams,
|
||||
expandTextLinks,
|
||||
describeReplyTarget,
|
||||
extractTelegramLocation,
|
||||
getTelegramTextParts,
|
||||
hasBotMention,
|
||||
normalizeForwardedContext,
|
||||
resolveTelegramDirectPeerId,
|
||||
resolveTelegramMediaPlaceholder,
|
||||
resolveTelegramThreadSpec,
|
||||
} from "./bot/helpers.js";
|
||||
import type { StickerMetadata, TelegramContext } from "./bot/types.js";
|
||||
import { resolveTelegramConversationRoute } from "./conversation-route.js";
|
||||
import { enforceTelegramDmAccess } from "./dm-access.js";
|
||||
import { isTelegramForumServiceMessage } from "./forum-service-message.js";
|
||||
import { evaluateTelegramGroupBaseAccess } from "./group-access.js";
|
||||
import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js";
|
||||
import {
|
||||
buildTelegramStatusReactionVariants,
|
||||
resolveTelegramAllowedEmojiReactions,
|
||||
@@ -83,80 +32,10 @@ import {
|
||||
resolveTelegramStatusReactionEmojis,
|
||||
} from "./status-reaction-variants.js";
|
||||
|
||||
export type TelegramMediaRef = {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
stickerMetadata?: StickerMetadata;
|
||||
};
|
||||
|
||||
type TelegramMessageContextOptions = {
|
||||
forceWasMentioned?: boolean;
|
||||
messageIdOverride?: string;
|
||||
};
|
||||
|
||||
type TelegramLogger = {
|
||||
info: (obj: Record<string, unknown>, msg: string) => void;
|
||||
};
|
||||
|
||||
type ResolveTelegramGroupConfig = (
|
||||
chatId: string | number,
|
||||
messageThreadId?: number,
|
||||
) => {
|
||||
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
};
|
||||
|
||||
type ResolveGroupActivation = (params: {
|
||||
chatId: string | number;
|
||||
agentId?: string;
|
||||
messageThreadId?: number;
|
||||
sessionKey?: string;
|
||||
}) => boolean | undefined;
|
||||
|
||||
type ResolveGroupRequireMention = (chatId: string | number) => boolean;
|
||||
|
||||
export type BuildTelegramMessageContextParams = {
|
||||
primaryCtx: TelegramContext;
|
||||
allMedia: TelegramMediaRef[];
|
||||
replyMedia?: TelegramMediaRef[];
|
||||
storeAllowFrom: string[];
|
||||
options?: TelegramMessageContextOptions;
|
||||
bot: Bot;
|
||||
cfg: OpenClawConfig;
|
||||
account: { accountId: string };
|
||||
historyLimit: number;
|
||||
groupHistories: Map<string, HistoryEntry[]>;
|
||||
dmPolicy: DmPolicy;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
ackReactionScope: "off" | "none" | "group-mentions" | "group-all" | "direct" | "all";
|
||||
logger: TelegramLogger;
|
||||
resolveGroupActivation: ResolveGroupActivation;
|
||||
resolveGroupRequireMention: ResolveGroupRequireMention;
|
||||
resolveTelegramGroupConfig: ResolveTelegramGroupConfig;
|
||||
/** Global (per-account) handler for sendChatAction 401 backoff (#27092). */
|
||||
sendChatActionHandler: import("./sendchataction-401-backoff.js").TelegramSendChatActionHandler;
|
||||
};
|
||||
|
||||
async function resolveStickerVisionSupport(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId?: string;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const catalog = await loadModelCatalog({ config: params.cfg });
|
||||
const defaultModel = resolveDefaultModelForAgent({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
return modelSupportsVision(entry);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export type {
|
||||
BuildTelegramMessageContextParams,
|
||||
TelegramMediaRef,
|
||||
} from "./bot-message-context.types.js";
|
||||
|
||||
export const buildTelegramMessageContext = async ({
|
||||
primaryCtx,
|
||||
@@ -375,7 +254,6 @@ export const buildTelegramMessageContext = async ({
|
||||
mainSessionKey: route.mainSessionKey,
|
||||
}),
|
||||
};
|
||||
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
|
||||
// Compute requireMention after access checks and final route selection.
|
||||
const activationOverride = resolveGroupActivation({
|
||||
chatId,
|
||||
@@ -397,179 +275,31 @@ export const buildTelegramMessageContext = async ({
|
||||
direction: "inbound",
|
||||
});
|
||||
|
||||
const botUsername = primaryCtx.me?.username?.toLowerCase();
|
||||
const messageTextParts = getTelegramTextParts(msg);
|
||||
const allowForCommands = isGroup ? effectiveGroupAllow : effectiveDmAllow;
|
||||
const senderAllowedForCommands = isSenderAllowed({
|
||||
allow: allowForCommands,
|
||||
const bodyResult = await resolveTelegramInboundBody({
|
||||
cfg,
|
||||
primaryCtx,
|
||||
msg,
|
||||
allMedia,
|
||||
isGroup,
|
||||
chatId,
|
||||
senderId,
|
||||
senderUsername,
|
||||
resolvedThreadId,
|
||||
routeAgentId: route.agentId,
|
||||
effectiveGroupAllow,
|
||||
effectiveDmAllow,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
requireMention,
|
||||
options,
|
||||
groupHistories,
|
||||
historyLimit,
|
||||
logger,
|
||||
});
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const hasControlCommandInMessage = hasControlCommand(messageTextParts.text, cfg, {
|
||||
botUsername,
|
||||
});
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
});
|
||||
const commandAuthorized = commandGate.commandAuthorized;
|
||||
const historyKey = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : undefined;
|
||||
|
||||
let placeholder = resolveTelegramMediaPlaceholder(msg) ?? "";
|
||||
|
||||
// Check if sticker has a cached description - if so, use it instead of sending the image
|
||||
const cachedStickerDescription = allMedia[0]?.stickerMetadata?.cachedDescription;
|
||||
const stickerSupportsVision = msg.sticker
|
||||
? await resolveStickerVisionSupport({ cfg, agentId: route.agentId })
|
||||
: false;
|
||||
const stickerCacheHit = Boolean(cachedStickerDescription) && !stickerSupportsVision;
|
||||
if (stickerCacheHit) {
|
||||
// Format cached description with sticker context
|
||||
const emoji = allMedia[0]?.stickerMetadata?.emoji;
|
||||
const setName = allMedia[0]?.stickerMetadata?.setName;
|
||||
const stickerContext = [emoji, setName ? `from "${setName}"` : null].filter(Boolean).join(" ");
|
||||
placeholder = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${cachedStickerDescription}`;
|
||||
}
|
||||
|
||||
const locationData = extractTelegramLocation(msg);
|
||||
const locationText = locationData ? formatLocationText(locationData) : undefined;
|
||||
const rawText = expandTextLinks(messageTextParts.text, messageTextParts.entities).trim();
|
||||
const hasUserText = Boolean(rawText || locationText);
|
||||
let rawBody = [rawText, locationText].filter(Boolean).join("\n").trim();
|
||||
if (!rawBody) {
|
||||
rawBody = placeholder;
|
||||
}
|
||||
if (!rawBody && allMedia.length === 0) {
|
||||
if (!bodyResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let bodyText = rawBody;
|
||||
const hasAudio = allMedia.some((media) => media.contentType?.startsWith("audio/"));
|
||||
|
||||
const disableAudioPreflight =
|
||||
firstDefined(
|
||||
topicConfig?.disableAudioPreflight,
|
||||
(groupConfig as TelegramGroupConfig | undefined)?.disableAudioPreflight,
|
||||
) === true;
|
||||
|
||||
// Preflight audio transcription for mention detection in groups
|
||||
// This allows voice notes to be checked for mentions before being dropped
|
||||
let preflightTranscript: string | undefined;
|
||||
const needsPreflightTranscription =
|
||||
isGroup &&
|
||||
requireMention &&
|
||||
hasAudio &&
|
||||
!hasUserText &&
|
||||
mentionRegexes.length > 0 &&
|
||||
!disableAudioPreflight;
|
||||
|
||||
if (needsPreflightTranscription) {
|
||||
try {
|
||||
const { transcribeFirstAudio } = await import("../media-understanding/audio-preflight.js");
|
||||
// Build a minimal context for transcription
|
||||
const tempCtx: MsgContext = {
|
||||
MediaPaths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
|
||||
MediaTypes:
|
||||
allMedia.length > 0
|
||||
? (allMedia.map((m) => m.contentType).filter(Boolean) as string[])
|
||||
: undefined,
|
||||
};
|
||||
preflightTranscript = await transcribeFirstAudio({
|
||||
ctx: tempCtx,
|
||||
cfg,
|
||||
agentDir: undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(`telegram: audio preflight transcription failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Replace audio placeholder with transcript when preflight succeeds.
|
||||
if (hasAudio && bodyText === "<media:audio>" && preflightTranscript) {
|
||||
bodyText = preflightTranscript;
|
||||
}
|
||||
|
||||
// Build bodyText fallback for messages that still have no text.
|
||||
if (!bodyText && allMedia.length > 0) {
|
||||
if (hasAudio) {
|
||||
bodyText = preflightTranscript || "<media:audio>";
|
||||
} else {
|
||||
bodyText = `<media:image>${allMedia.length > 1 ? ` (${allMedia.length} images)` : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
const hasAnyMention = messageTextParts.entities.some((ent) => ent.type === "mention");
|
||||
const explicitlyMentioned = botUsername ? hasBotMention(msg, botUsername) : false;
|
||||
|
||||
const computedWasMentioned = matchesMentionWithExplicit({
|
||||
text: messageTextParts.text,
|
||||
mentionRegexes,
|
||||
explicit: {
|
||||
hasAnyMention,
|
||||
isExplicitlyMentioned: explicitlyMentioned,
|
||||
canResolveExplicit: Boolean(botUsername),
|
||||
},
|
||||
transcript: preflightTranscript,
|
||||
});
|
||||
const wasMentioned = options?.forceWasMentioned === true ? true : computedWasMentioned;
|
||||
if (isGroup && commandGate.shouldBlock) {
|
||||
logInboundDrop({
|
||||
log: logVerbose,
|
||||
channel: "telegram",
|
||||
reason: "control command (unauthorized)",
|
||||
target: senderId ?? "unknown",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
// Reply-chain detection: replying to a bot message acts like an implicit mention.
|
||||
// Exclude forum-topic service messages (auto-generated "Topic created" etc. messages
|
||||
// by the bot) so that every message inside a bot-created topic does not incorrectly
|
||||
// bypass requireMention (#32256).
|
||||
// We detect service messages by the presence of Telegram's forum_topic_* fields
|
||||
// rather than by the absence of text/caption, because legitimate bot media messages
|
||||
// (stickers, voice notes, captionless photos) also lack text/caption.
|
||||
const botId = primaryCtx.me?.id;
|
||||
const replyFromId = msg.reply_to_message?.from?.id;
|
||||
const replyToBotMessage = botId != null && replyFromId === botId;
|
||||
const isReplyToServiceMessage =
|
||||
replyToBotMessage && isTelegramForumServiceMessage(msg.reply_to_message);
|
||||
const implicitMention = replyToBotMessage && !isReplyToServiceMessage;
|
||||
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
|
||||
const mentionGate = resolveMentionGatingWithBypass({
|
||||
isGroup,
|
||||
requireMention: Boolean(requireMention),
|
||||
canDetectMention,
|
||||
wasMentioned,
|
||||
implicitMention: isGroup && Boolean(requireMention) && implicitMention,
|
||||
hasAnyMention,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
commandAuthorized,
|
||||
});
|
||||
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
|
||||
if (isGroup && requireMention && canDetectMention) {
|
||||
if (mentionGate.shouldSkip) {
|
||||
logger.info({ chatId, reason: "no-mention" }, "skipping group message");
|
||||
recordPendingHistoryEntryIfEnabled({
|
||||
historyMap: groupHistories,
|
||||
historyKey: historyKey ?? "",
|
||||
limit: historyLimit,
|
||||
entry: historyKey
|
||||
? {
|
||||
sender: buildSenderLabel(msg, senderId || chatId),
|
||||
body: rawBody,
|
||||
timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||
messageId: typeof msg.message_id === "number" ? String(msg.message_id) : undefined,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await ensureConfiguredBindingReady())) {
|
||||
return null;
|
||||
}
|
||||
@@ -589,9 +319,9 @@ export const buildTelegramMessageContext = async ({
|
||||
isGroup,
|
||||
isMentionableGroup: isGroup,
|
||||
requireMention: Boolean(requireMention),
|
||||
canDetectMention,
|
||||
effectiveWasMentioned,
|
||||
shouldBypassMention: mentionGate.shouldBypassMention,
|
||||
canDetectMention: bodyResult.canDetectMention,
|
||||
effectiveWasMentioned: bodyResult.effectiveWasMentioned,
|
||||
shouldBypassMention: bodyResult.shouldBypassMention,
|
||||
}),
|
||||
);
|
||||
const api = bot.api as unknown as {
|
||||
@@ -683,223 +413,35 @@ export const buildTelegramMessageContext = async ({
|
||||
)
|
||||
: null;
|
||||
|
||||
const replyTarget = describeReplyTarget(msg);
|
||||
const forwardOrigin = normalizeForwardedContext(msg);
|
||||
// Build forward annotation for reply target if it was itself a forwarded message (issue #9619)
|
||||
const replyForwardAnnotation = replyTarget?.forwardedFrom
|
||||
? `[Forwarded from ${replyTarget.forwardedFrom.from}${
|
||||
replyTarget.forwardedFrom.date
|
||||
? ` at ${new Date(replyTarget.forwardedFrom.date * 1000).toISOString()}`
|
||||
: ""
|
||||
}]\n`
|
||||
: "";
|
||||
const replySuffix = replyTarget
|
||||
? replyTarget.kind === "quote"
|
||||
? `\n\n[Quoting ${replyTarget.sender}${
|
||||
replyTarget.id ? ` id:${replyTarget.id}` : ""
|
||||
}]\n${replyForwardAnnotation}"${replyTarget.body}"\n[/Quoting]`
|
||||
: `\n\n[Replying to ${replyTarget.sender}${
|
||||
replyTarget.id ? ` id:${replyTarget.id}` : ""
|
||||
}]\n${replyForwardAnnotation}${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
|
||||
? (groupLabel ?? `group:${chatId}`)
|
||||
: buildSenderLabel(msg, senderId || chatId);
|
||||
const storePath = resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
||||
const previousTimestamp = readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: sessionKey,
|
||||
});
|
||||
const body = formatInboundEnvelope({
|
||||
channel: "Telegram",
|
||||
from: conversationLabel,
|
||||
timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||
body: `${forwardPrefix}${bodyText}${replySuffix}`,
|
||||
chatType: isGroup ? "group" : "direct",
|
||||
sender: {
|
||||
name: senderName,
|
||||
username: senderUsername || undefined,
|
||||
id: senderId || undefined,
|
||||
},
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
});
|
||||
let combinedBody = body;
|
||||
if (isGroup && historyKey && historyLimit > 0) {
|
||||
combinedBody = buildPendingHistoryContextFromMap({
|
||||
historyMap: groupHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (entry) =>
|
||||
formatInboundEnvelope({
|
||||
channel: "Telegram",
|
||||
from: groupLabel ?? `group:${chatId}`,
|
||||
timestamp: entry.timestamp,
|
||||
body: `${entry.body} [id:${entry.messageId ?? "unknown"} chat:${chatId}]`,
|
||||
chatType: "group",
|
||||
senderLabel: entry.sender,
|
||||
envelope: envelopeOptions,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({
|
||||
const { ctxPayload, skillFilter } = await buildTelegramInboundContextPayload({
|
||||
cfg,
|
||||
primaryCtx,
|
||||
msg,
|
||||
allMedia,
|
||||
replyMedia,
|
||||
isGroup,
|
||||
isForum,
|
||||
chatId,
|
||||
senderId,
|
||||
senderUsername,
|
||||
resolvedThreadId,
|
||||
dmThreadId,
|
||||
threadSpec,
|
||||
route,
|
||||
rawBody: bodyResult.rawBody,
|
||||
bodyText: bodyResult.bodyText,
|
||||
historyKey: bodyResult.historyKey,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
stickerCacheHit: bodyResult.stickerCacheHit,
|
||||
effectiveWasMentioned: bodyResult.effectiveWasMentioned,
|
||||
locationData: bodyResult.locationData,
|
||||
options,
|
||||
dmAllowFrom,
|
||||
commandAuthorized: bodyResult.commandAuthorized,
|
||||
});
|
||||
const commandBody = normalizeCommandBody(rawBody, { botUsername });
|
||||
const inboundHistory =
|
||||
isGroup && historyKey && historyLimit > 0
|
||||
? (groupHistories.get(historyKey) ?? []).map((entry) => ({
|
||||
sender: entry.sender,
|
||||
body: entry.body,
|
||||
timestamp: entry.timestamp,
|
||||
}))
|
||||
: undefined;
|
||||
const currentMediaForContext = stickerCacheHit ? [] : allMedia;
|
||||
const contextMedia = [...currentMediaForContext, ...replyMedia];
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
// Agent prompt should be the raw user text only; metadata/context is provided via system prompt.
|
||||
BodyForAgent: bodyText,
|
||||
InboundHistory: inboundHistory,
|
||||
RawBody: rawBody,
|
||||
CommandBody: commandBody,
|
||||
From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`,
|
||||
To: `telegram:${chatId}`,
|
||||
SessionKey: sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
ConversationLabel: conversationLabel,
|
||||
GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined,
|
||||
GroupSystemPrompt: isGroup || (!isGroup && groupConfig) ? groupSystemPrompt : undefined,
|
||||
SenderName: senderName,
|
||||
SenderId: senderId || undefined,
|
||||
SenderUsername: senderUsername || undefined,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
MessageSid: options?.messageIdOverride ?? String(msg.message_id),
|
||||
ReplyToId: replyTarget?.id,
|
||||
ReplyToBody: replyTarget?.body,
|
||||
ReplyToSender: replyTarget?.sender,
|
||||
ReplyToIsQuote: replyTarget?.kind === "quote" ? true : undefined,
|
||||
// Forward context from reply target (issue #9619: forward + comment bundling)
|
||||
ReplyToForwardedFrom: replyTarget?.forwardedFrom?.from,
|
||||
ReplyToForwardedFromType: replyTarget?.forwardedFrom?.fromType,
|
||||
ReplyToForwardedFromId: replyTarget?.forwardedFrom?.fromId,
|
||||
ReplyToForwardedFromUsername: replyTarget?.forwardedFrom?.fromUsername,
|
||||
ReplyToForwardedFromTitle: replyTarget?.forwardedFrom?.fromTitle,
|
||||
ReplyToForwardedDate: replyTarget?.forwardedFrom?.date
|
||||
? replyTarget.forwardedFrom.date * 1000
|
||||
: undefined,
|
||||
ForwardedFrom: forwardOrigin?.from,
|
||||
ForwardedFromType: forwardOrigin?.fromType,
|
||||
ForwardedFromId: forwardOrigin?.fromId,
|
||||
ForwardedFromUsername: forwardOrigin?.fromUsername,
|
||||
ForwardedFromTitle: forwardOrigin?.fromTitle,
|
||||
ForwardedFromSignature: forwardOrigin?.fromSignature,
|
||||
ForwardedFromChatType: forwardOrigin?.fromChatType,
|
||||
ForwardedFromMessageId: forwardOrigin?.fromMessageId,
|
||||
ForwardedDate: forwardOrigin?.date ? forwardOrigin.date * 1000 : undefined,
|
||||
Timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
|
||||
// Filter out cached stickers from current-message media; reply media is still valid context.
|
||||
MediaPath: contextMedia.length > 0 ? contextMedia[0]?.path : undefined,
|
||||
MediaType: contextMedia.length > 0 ? contextMedia[0]?.contentType : undefined,
|
||||
MediaUrl: contextMedia.length > 0 ? contextMedia[0]?.path : undefined,
|
||||
MediaPaths: contextMedia.length > 0 ? contextMedia.map((m) => m.path) : undefined,
|
||||
MediaUrls: contextMedia.length > 0 ? contextMedia.map((m) => m.path) : undefined,
|
||||
MediaTypes:
|
||||
contextMedia.length > 0
|
||||
? (contextMedia.map((m) => m.contentType).filter(Boolean) as string[])
|
||||
: undefined,
|
||||
Sticker: allMedia[0]?.stickerMetadata,
|
||||
StickerMediaIncluded: allMedia[0]?.stickerMetadata ? !stickerCacheHit : undefined,
|
||||
...(locationData ? toLocationContext(locationData) : undefined),
|
||||
CommandAuthorized: commandAuthorized,
|
||||
// For groups: use resolved forum topic id; for DMs: use raw messageThreadId
|
||||
MessageThreadId: threadSpec.id,
|
||||
IsForum: isForum,
|
||||
// Originating channel for reply routing.
|
||||
OriginatingChannel: "telegram" as const,
|
||||
OriginatingTo: `telegram:${chatId}`,
|
||||
});
|
||||
|
||||
const pinnedMainDmOwner = !isGroup
|
||||
? resolvePinnedMainDmOwnerFromAllowlist({
|
||||
dmScope: cfg.session?.dmScope,
|
||||
allowFrom: dmAllowFrom,
|
||||
normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0],
|
||||
})
|
||||
: null;
|
||||
const updateLastRouteSessionKey = resolveInboundLastRouteSessionKey({
|
||||
route,
|
||||
sessionKey,
|
||||
});
|
||||
|
||||
await recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? sessionKey,
|
||||
ctx: ctxPayload,
|
||||
updateLastRoute: !isGroup
|
||||
? {
|
||||
sessionKey: updateLastRouteSessionKey,
|
||||
channel: "telegram",
|
||||
to: `telegram:${chatId}`,
|
||||
accountId: route.accountId,
|
||||
// Preserve DM topic threadId for replies (fixes #8891)
|
||||
threadId: dmThreadId != null ? String(dmThreadId) : undefined,
|
||||
mainDmOwnerPin:
|
||||
updateLastRouteSessionKey === route.mainSessionKey && pinnedMainDmOwner && senderId
|
||||
? {
|
||||
ownerRecipient: pinnedMainDmOwner,
|
||||
senderRecipient: senderId,
|
||||
onSkip: ({ ownerRecipient, senderRecipient }) => {
|
||||
logVerbose(
|
||||
`telegram: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
|
||||
);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
onRecordError: (err) => {
|
||||
logVerbose(`telegram: failed updating session meta: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
if (replyTarget && shouldLogVerbose()) {
|
||||
const preview = replyTarget.body.replace(/\s+/g, " ").slice(0, 120);
|
||||
logVerbose(
|
||||
`telegram reply-context: replyToId=${replyTarget.id} replyToSender=${replyTarget.sender} replyToBody="${preview}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (forwardOrigin && shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`telegram forward-context: forwardedFrom="${forwardOrigin.from}" type=${forwardOrigin.fromType}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
const preview = body.slice(0, 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,
|
||||
@@ -911,7 +453,7 @@ export const buildTelegramMessageContext = async ({
|
||||
threadSpec,
|
||||
replyThreadId,
|
||||
isForum,
|
||||
historyKey,
|
||||
historyKey: bodyResult.historyKey,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
route,
|
||||
|
||||
65
src/telegram/bot-message-context.types.ts
Normal file
65
src/telegram/bot-message-context.types.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { Bot } from "grammy";
|
||||
import type { HistoryEntry } from "../auto-reply/reply/history.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type {
|
||||
DmPolicy,
|
||||
TelegramDirectConfig,
|
||||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} from "../config/types.js";
|
||||
import type { StickerMetadata, TelegramContext } from "./bot/types.js";
|
||||
|
||||
export type TelegramMediaRef = {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
stickerMetadata?: StickerMetadata;
|
||||
};
|
||||
|
||||
export type TelegramMessageContextOptions = {
|
||||
forceWasMentioned?: boolean;
|
||||
messageIdOverride?: string;
|
||||
};
|
||||
|
||||
export type TelegramLogger = {
|
||||
info: (obj: Record<string, unknown>, msg: string) => void;
|
||||
};
|
||||
|
||||
export type ResolveTelegramGroupConfig = (
|
||||
chatId: string | number,
|
||||
messageThreadId?: number,
|
||||
) => {
|
||||
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
};
|
||||
|
||||
export type ResolveGroupActivation = (params: {
|
||||
chatId: string | number;
|
||||
agentId?: string;
|
||||
messageThreadId?: number;
|
||||
sessionKey?: string;
|
||||
}) => boolean | undefined;
|
||||
|
||||
export type ResolveGroupRequireMention = (chatId: string | number) => boolean;
|
||||
|
||||
export type BuildTelegramMessageContextParams = {
|
||||
primaryCtx: TelegramContext;
|
||||
allMedia: TelegramMediaRef[];
|
||||
replyMedia?: TelegramMediaRef[];
|
||||
storeAllowFrom: string[];
|
||||
options?: TelegramMessageContextOptions;
|
||||
bot: Bot;
|
||||
cfg: OpenClawConfig;
|
||||
account: { accountId: string };
|
||||
historyLimit: number;
|
||||
groupHistories: Map<string, HistoryEntry[]>;
|
||||
dmPolicy: DmPolicy;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
ackReactionScope: "off" | "none" | "group-mentions" | "group-all" | "direct" | "all";
|
||||
logger: TelegramLogger;
|
||||
resolveGroupActivation: ResolveGroupActivation;
|
||||
resolveGroupRequireMention: ResolveGroupRequireMention;
|
||||
resolveTelegramGroupConfig: ResolveTelegramGroupConfig;
|
||||
/** Global (per-account) handler for sendChatAction 401 backoff (#27092). */
|
||||
sendChatActionHandler: import("./sendchataction-401-backoff.js").TelegramSendChatActionHandler;
|
||||
};
|
||||
Reference in New Issue
Block a user