| import { ensureConfiguredAcpRouteReady } from "../acp/persistent-bindings.route.js"; |
| import { resolveAckReaction } from "../agents/identity.js"; |
| import { shouldAckReaction as shouldAckReactionGate } from "../channels/ack-reactions.js"; |
| import { logInboundDrop } from "../channels/logging.js"; |
| import { |
| createStatusReactionController, |
| type StatusReactionController, |
| } from "../channels/status-reactions.js"; |
| import { loadConfig } from "../config/config.js"; |
| import type { TelegramDirectConfig, TelegramGroupConfig } from "../config/types.js"; |
| import { logVerbose } from "../globals.js"; |
| import { recordChannelActivity } from "../infra/channel-activity.js"; |
| import { buildAgentSessionKey, deriveLastRoutePolicy } from "../routing/resolve-route.js"; |
| import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "../routing/session-key.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 { |
| buildTypingThreadParams, |
| extractTelegramForumFlag, |
| resolveTelegramForumFlag, |
| resolveTelegramDirectPeerId, |
| resolveTelegramThreadSpec, |
| } from "./bot/helpers.js"; |
| import { resolveTelegramConversationRoute } from "./conversation-route.js"; |
| import { enforceTelegramDmAccess } from "./dm-access.js"; |
| import { evaluateTelegramGroupBaseAccess } from "./group-access.js"; |
| import { |
| buildTelegramStatusReactionVariants, |
| resolveTelegramAllowedEmojiReactions, |
| resolveTelegramReactionVariant, |
| resolveTelegramStatusReactionEmojis, |
| } from "./status-reaction-variants.js"; |
|
|
| export type { |
| BuildTelegramMessageContextParams, |
| TelegramMediaRef, |
| } from "./bot-message-context.types.js"; |
|
|
| export const buildTelegramMessageContext = async ({ |
| primaryCtx, |
| allMedia, |
| replyMedia = [], |
| storeAllowFrom, |
| options, |
| bot, |
| cfg, |
| account, |
| historyLimit, |
| groupHistories, |
| dmPolicy, |
| allowFrom, |
| groupAllowFrom, |
| ackReactionScope, |
| logger, |
| resolveGroupActivation, |
| resolveGroupRequireMention, |
| resolveTelegramGroupConfig, |
| sendChatActionHandler, |
| }: BuildTelegramMessageContextParams) => { |
| const msg = primaryCtx.message; |
| const chatId = msg.chat.id; |
| const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; |
| const senderId = msg.from?.id ? String(msg.from.id) : ""; |
| const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; |
| const forumLookupGetChat = |
| typeof bot.api.getChat === "function" ? bot.api.getChat.bind(bot.api) : undefined; |
| const isForum = await resolveTelegramForumFlag({ |
| chatId, |
| chatType: msg.chat.type, |
| isGroup, |
| isForum: extractTelegramForumFlag(msg.chat), |
| getChat: forumLookupGetChat, |
| }); |
| const threadSpec = resolveTelegramThreadSpec({ |
| isGroup, |
| isForum, |
| messageThreadId, |
| }); |
| const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined; |
| const replyThreadId = threadSpec.id; |
| const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined; |
| const threadIdForConfig = resolvedThreadId ?? dmThreadId; |
| const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, threadIdForConfig); |
| |
| const effectiveDmPolicy = |
| !isGroup && groupConfig && "dmPolicy" in groupConfig |
| ? (groupConfig.dmPolicy ?? dmPolicy) |
| : dmPolicy; |
| |
| const freshCfg = loadConfig(); |
| let { route, configuredBinding, configuredBindingSessionKey } = resolveTelegramConversationRoute({ |
| cfg: freshCfg, |
| accountId: account.accountId, |
| chatId, |
| isGroup, |
| resolvedThreadId, |
| replyThreadId, |
| senderId, |
| topicAgentId: topicConfig?.agentId, |
| }); |
| const requiresExplicitAccountBinding = ( |
| candidate: ReturnType<typeof resolveTelegramConversationRoute>["route"], |
| ): boolean => candidate.accountId !== DEFAULT_ACCOUNT_ID && candidate.matchedBy === "default"; |
| const isNamedAccountFallback = requiresExplicitAccountBinding(route); |
| |
| |
| if (isNamedAccountFallback && isGroup) { |
| logInboundDrop({ |
| log: logVerbose, |
| channel: "telegram", |
| reason: "non-default account requires explicit binding", |
| target: route.accountId, |
| }); |
| return null; |
| } |
| |
| const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom); |
| |
| const dmAllowFrom = groupAllowOverride ?? allowFrom; |
| const effectiveDmAllow = normalizeDmAllowFromWithStore({ |
| allowFrom: dmAllowFrom, |
| storeAllowFrom, |
| dmPolicy: effectiveDmPolicy, |
| }); |
| |
| const effectiveGroupAllow = normalizeAllowFrom(groupAllowOverride ?? groupAllowFrom); |
| const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined"; |
| const senderUsername = msg.from?.username ?? ""; |
| const baseAccess = evaluateTelegramGroupBaseAccess({ |
| isGroup, |
| groupConfig, |
| topicConfig, |
| hasGroupAllowOverride, |
| effectiveGroupAllow, |
| senderId, |
| senderUsername, |
| enforceAllowOverride: true, |
| requireSenderForAllowOverride: false, |
| }); |
| if (!baseAccess.allowed) { |
| if (baseAccess.reason === "group-disabled") { |
| logVerbose(`Blocked telegram group ${chatId} (group disabled)`); |
| return null; |
| } |
| if (baseAccess.reason === "topic-disabled") { |
| logVerbose( |
| `Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`, |
| ); |
| return null; |
| } |
| logVerbose( |
| isGroup |
| ? `Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)` |
| : `Blocked telegram DM sender ${senderId || "unknown"} (DM allowFrom override)`, |
| ); |
| return null; |
| } |
|
|
| const requireTopic = (groupConfig as TelegramDirectConfig | undefined)?.requireTopic; |
| const topicRequiredButMissing = !isGroup && requireTopic === true && dmThreadId == null; |
| if (topicRequiredButMissing) { |
| logVerbose(`Blocked telegram DM ${chatId}: requireTopic=true but no topic present`); |
| return null; |
| } |
|
|
| const sendTyping = async () => { |
| await withTelegramApiErrorLogging({ |
| operation: "sendChatAction", |
| fn: () => |
| sendChatActionHandler.sendChatAction( |
| chatId, |
| "typing", |
| buildTypingThreadParams(replyThreadId), |
| ), |
| }); |
| }; |
|
|
| const sendRecordVoice = async () => { |
| try { |
| await withTelegramApiErrorLogging({ |
| operation: "sendChatAction", |
| fn: () => |
| sendChatActionHandler.sendChatAction( |
| chatId, |
| "record_voice", |
| buildTypingThreadParams(replyThreadId), |
| ), |
| }); |
| } catch (err) { |
| logVerbose(`telegram record_voice cue failed for chat ${chatId}: ${String(err)}`); |
| } |
| }; |
|
|
| if ( |
| !(await enforceTelegramDmAccess({ |
| isGroup, |
| dmPolicy: effectiveDmPolicy, |
| msg, |
| chatId, |
| effectiveDmAllow, |
| accountId: account.accountId, |
| bot, |
| logger, |
| })) |
| ) { |
| return null; |
| } |
| const ensureConfiguredBindingReady = async (): Promise<boolean> => { |
| if (!configuredBinding) { |
| return true; |
| } |
| const ensured = await ensureConfiguredAcpRouteReady({ |
| cfg: freshCfg, |
| configuredBinding, |
| }); |
| if (ensured.ok) { |
| logVerbose( |
| `telegram: using configured ACP binding for ${configuredBinding.spec.conversationId} -> ${configuredBindingSessionKey}`, |
| ); |
| return true; |
| } |
| logVerbose( |
| `telegram: configured ACP binding unavailable for ${configuredBinding.spec.conversationId}: ${ensured.error}`, |
| ); |
| logInboundDrop({ |
| log: logVerbose, |
| channel: "telegram", |
| reason: "configured ACP binding unavailable", |
| target: configuredBinding.spec.conversationId, |
| }); |
| return false; |
| }; |
|
|
| const baseSessionKey = isNamedAccountFallback |
| ? buildAgentSessionKey({ |
| agentId: route.agentId, |
| channel: "telegram", |
| accountId: route.accountId, |
| peer: { |
| kind: "direct", |
| id: resolveTelegramDirectPeerId({ |
| chatId, |
| senderId, |
| }), |
| }, |
| dmScope: "per-account-channel-peer", |
| identityLinks: freshCfg.session?.identityLinks, |
| }).toLowerCase() |
| : route.sessionKey; |
| |
| const threadKeys = |
| dmThreadId != null |
| ? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` }) |
| : null; |
| const sessionKey = threadKeys?.sessionKey ?? baseSessionKey; |
| route = { |
| ...route, |
| sessionKey, |
| lastRoutePolicy: deriveLastRoutePolicy({ |
| sessionKey, |
| mainSessionKey: route.mainSessionKey, |
| }), |
| }; |
| |
| const activationOverride = resolveGroupActivation({ |
| chatId, |
| messageThreadId: resolvedThreadId, |
| sessionKey: sessionKey, |
| agentId: route.agentId, |
| }); |
| const baseRequireMention = resolveGroupRequireMention(chatId); |
| const requireMention = firstDefined( |
| activationOverride, |
| topicConfig?.requireMention, |
| (groupConfig as TelegramGroupConfig | undefined)?.requireMention, |
| baseRequireMention, |
| ); |
|
|
| recordChannelActivity({ |
| channel: "telegram", |
| accountId: account.accountId, |
| direction: "inbound", |
| }); |
|
|
| 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, |
| }); |
| if (!bodyResult) { |
| return null; |
| } |
|
|
| if (!(await ensureConfiguredBindingReady())) { |
| return null; |
| } |
|
|
| |
| const ackReaction = resolveAckReaction(cfg, route.agentId, { |
| channel: "telegram", |
| accountId: account.accountId, |
| }); |
| const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; |
| const shouldAckReaction = () => |
| Boolean( |
| ackReaction && |
| shouldAckReactionGate({ |
| scope: ackReactionScope, |
| isDirect: !isGroup, |
| isGroup, |
| isMentionableGroup: isGroup, |
| requireMention: Boolean(requireMention), |
| canDetectMention: bodyResult.canDetectMention, |
| effectiveWasMentioned: bodyResult.effectiveWasMentioned, |
| shouldBypassMention: bodyResult.shouldBypassMention, |
| }), |
| ); |
| const api = bot.api as unknown as { |
| setMessageReaction?: ( |
| chatId: number | string, |
| messageId: number, |
| reactions: Array<{ type: "emoji"; emoji: string }>, |
| ) => Promise<void>; |
| getChat?: (chatId: number | string) => Promise<unknown>; |
| }; |
| const reactionApi = |
| typeof api.setMessageReaction === "function" ? api.setMessageReaction.bind(api) : null; |
| const getChatApi = typeof api.getChat === "function" ? api.getChat.bind(api) : null; |
|
|
| |
| const statusReactionsConfig = cfg.messages?.statusReactions; |
| const statusReactionsEnabled = |
| statusReactionsConfig?.enabled === true && Boolean(reactionApi) && shouldAckReaction(); |
| const resolvedStatusReactionEmojis = resolveTelegramStatusReactionEmojis({ |
| initialEmoji: ackReaction, |
| overrides: statusReactionsConfig?.emojis, |
| }); |
| const statusReactionVariantsByEmoji = buildTelegramStatusReactionVariants( |
| resolvedStatusReactionEmojis, |
| ); |
| let allowedStatusReactionEmojisPromise: Promise<Set<string> | null> | null = null; |
| const statusReactionController: StatusReactionController | null = |
| statusReactionsEnabled && msg.message_id |
| ? createStatusReactionController({ |
| enabled: true, |
| adapter: { |
| setReaction: async (emoji: string) => { |
| if (reactionApi) { |
| if (!allowedStatusReactionEmojisPromise) { |
| allowedStatusReactionEmojisPromise = resolveTelegramAllowedEmojiReactions({ |
| chat: msg.chat, |
| chatId, |
| getChat: getChatApi ?? undefined, |
| }).catch((err) => { |
| logVerbose( |
| `telegram status-reaction available_reactions lookup failed for chat ${chatId}: ${String(err)}`, |
| ); |
| return null; |
| }); |
| } |
| const allowedStatusReactionEmojis = await allowedStatusReactionEmojisPromise; |
| const resolvedEmoji = resolveTelegramReactionVariant({ |
| requestedEmoji: emoji, |
| variantsByRequestedEmoji: statusReactionVariantsByEmoji, |
| allowedEmojiReactions: allowedStatusReactionEmojis, |
| }); |
| if (!resolvedEmoji) { |
| return; |
| } |
| await reactionApi(chatId, msg.message_id, [ |
| { type: "emoji", emoji: resolvedEmoji }, |
| ]); |
| } |
| }, |
| |
| }, |
| initialEmoji: ackReaction, |
| emojis: resolvedStatusReactionEmojis, |
| timing: statusReactionsConfig?.timing, |
| onError: (err) => { |
| logVerbose(`telegram status-reaction error for chat ${chatId}: ${String(err)}`); |
| }, |
| }) |
| : null; |
|
|
| |
| const ackReactionPromise = statusReactionController |
| ? shouldAckReaction() |
| ? Promise.resolve(statusReactionController.setQueued()).then( |
| () => true, |
| () => false, |
| ) |
| : null |
| : shouldAckReaction() && msg.message_id && reactionApi |
| ? withTelegramApiErrorLogging({ |
| operation: "setMessageReaction", |
| fn: () => reactionApi(chatId, msg.message_id, [{ type: "emoji", emoji: ackReaction }]), |
| }).then( |
| () => true, |
| (err) => { |
| logVerbose(`telegram react failed for chat ${chatId}: ${String(err)}`); |
| return false; |
| }, |
| ) |
| : null; |
|
|
| 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, |
| }); |
|
|
| return { |
| ctxPayload, |
| primaryCtx, |
| msg, |
| chatId, |
| isGroup, |
| resolvedThreadId, |
| threadSpec, |
| replyThreadId, |
| isForum, |
| historyKey: bodyResult.historyKey, |
| historyLimit, |
| groupHistories, |
| route, |
| skillFilter, |
| sendTyping, |
| sendRecordVoice, |
| ackReactionPromise, |
| reactionApi, |
| removeAckAfterReply, |
| statusReactionController, |
| accountId: account.accountId, |
| }; |
| }; |
|
|
| export type TelegramMessageContext = NonNullable< |
| Awaited<ReturnType<typeof buildTelegramMessageContext>> |
| >; |
|
|