refactor(telegram): consolidate bot handlers into named modules (#122174)

The bot-handlers family was 22 files behind a 22-line fan-out: six files holding one callback switch, four holding one inbound pipeline, every interface a derived ReturnType, processMessage taking 8 positional args, and its params type declared inside bot-native-commands.ts. Three named coordinators (inbound pipeline, callback router, event bindings) now sit behind the same registerTelegramHandlers entry with hand-written leaf contracts; the factory graph, 16 slice/barrel files, and the type shim are deleted. Behavior-neutral: bot.test.ts and the ingress e2e byte-identical to main (SHA-256-pinned) and green; +185 production LOC accepted as the written-contract tradeoff.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Ayaan Zaidi
2026-08-11 23:48:16 +05:30
committed by GitHub
parent 5bc7efe746
commit d2825c70a5
40 changed files with 2526 additions and 2260 deletions
+20 -1
View File
@@ -431,7 +431,26 @@ export function createTelegramBotCore(
resolveGroupRequireMention,
resolveTelegramGroupConfig,
shouldSkipUpdate,
processMessage,
processMessage: async ({
ctx,
allMedia,
storeAllowFrom,
turnContext,
options,
replyMedia,
replyChain,
promptContext,
}) =>
await processMessage(
ctx,
allMedia,
storeAllowFrom,
turnContext,
options,
replyMedia,
replyChain,
promptContext,
),
logger,
telegramDeps,
nativeCommandCallbackDispatcher,
@@ -1,118 +0,0 @@
// Telegram group policy checks shared by message-like and callback events.
import type {
OpenClawConfig,
TelegramAccountConfig,
TelegramGroupConfig,
TelegramTopicConfig,
} from "openclaw/plugin-sdk/config-contracts";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { NormalizedAllowFrom } from "./bot-access.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import {
evaluateTelegramGroupBaseAccess,
evaluateTelegramGroupPolicyAccess,
} from "./group-access.js";
export function shouldSkipTelegramGroupMessage(
params: {
isGroup: boolean;
chatId: string | number;
chatTitle?: string;
resolvedThreadId?: number;
senderId: string;
senderUsername: string;
effectiveGroupAllow: NormalizedAllowFrom;
hasGroupAllowOverride: boolean;
groupConfig?: TelegramGroupConfig;
topicConfig?: TelegramTopicConfig;
cfg: OpenClawConfig;
telegramCfg: TelegramAccountConfig;
},
runtime: Pick<RegisterTelegramHandlerParams, "logger" | "resolveGroupPolicy">,
): boolean {
const {
isGroup,
chatId,
chatTitle,
resolvedThreadId,
senderId,
senderUsername,
effectiveGroupAllow,
hasGroupAllowOverride,
groupConfig,
topicConfig,
cfg,
telegramCfg,
} = params;
const baseAccess = evaluateTelegramGroupBaseAccess({
isGroup,
groupConfig,
topicConfig,
hasGroupAllowOverride,
effectiveGroupAllow,
senderId,
senderUsername,
enforceAllowOverride: true,
requireSenderForAllowOverride: true,
});
if (!baseAccess.allowed) {
if (baseAccess.reason === "group-disabled") {
logVerbose(`Blocked telegram group ${chatId} (group disabled)`);
return true;
}
if (baseAccess.reason === "topic-disabled") {
logVerbose(
`Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
);
return true;
}
logVerbose(`Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`);
return true;
}
if (!isGroup) {
return false;
}
const policyAccess = evaluateTelegramGroupPolicyAccess({
isGroup,
chatId,
cfg,
telegramCfg,
topicConfig,
groupConfig,
effectiveGroupAllow,
senderId,
senderUsername,
resolveGroupPolicy: runtime.resolveGroupPolicy,
enforcePolicy: true,
enforceAllowlistAuthorization: true,
allowEmptyAllowlistEntries: false,
requireSenderForAllowlistAuthorization: true,
checkChatAllowlist: true,
});
if (policyAccess.allowed) {
return false;
}
if (policyAccess.reason === "group-policy-disabled") {
logVerbose("Blocked telegram group message (groupPolicy: disabled)");
return true;
}
if (policyAccess.reason === "group-policy-allowlist-no-sender") {
logVerbose("Blocked telegram group message (no sender ID, groupPolicy: allowlist)");
return true;
}
if (policyAccess.reason === "group-policy-allowlist-empty") {
logVerbose(
"Blocked telegram group message (groupPolicy: allowlist, no group allowlist entries)",
);
return true;
}
if (policyAccess.reason === "group-policy-allowlist-unauthorized") {
logVerbose(`Blocked telegram group message from ${senderId} (groupPolicy: allowlist)`);
return true;
}
runtime.logger.info(
{ chatId, title: chatTitle, reason: "not-allowed" },
"skipping group message",
);
return true;
}
@@ -1,6 +1,8 @@
import type { Message } from "grammy/types";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import { questionGatewayRuntime } from "openclaw/plugin-sdk/question-gateway-runtime";
import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js";
import { buildTelegramThreadParams, resolveTelegramMessageThreadSpec } from "./bot/helpers.js";
import type { TelegramQuestionCallback } from "./question-callback-data.js";
import { buildInlineKeyboard } from "./send.js";
export type TelegramCallbackButton = {
@@ -25,6 +27,11 @@ export interface TelegramCallbackMessageActions {
editCallbackButtons: (
buttons: TelegramCallbackButton[][],
) => ReturnType<RegisterTelegramHandlerParams["bot"]["api"]["editMessageReplyMarkup"]>;
editCallbackMessageWithButtons: (
text: string,
buttons: TelegramCallbackButton[][],
extra?: { parse_mode?: "HTML" | "Markdown" | "MarkdownV2" },
) => Promise<void>;
deleteCallbackMessage: () => ReturnType<
RegisterTelegramHandlerParams["bot"]["api"]["deleteMessage"]
>;
@@ -92,11 +99,66 @@ export function createTelegramCallbackMessageActions(params: {
return await bot.api.sendMessage(callbackMessage.chat.id, text, mergedParams);
};
const editCallbackMessageWithButtons = async (
text: string,
buttons: TelegramCallbackButton[][],
extra?: { parse_mode?: "HTML" | "Markdown" | "MarkdownV2" },
) => {
const keyboard = buildInlineKeyboard(buttons);
const editParams = keyboard ? { reply_markup: keyboard, ...extra } : extra;
try {
await editCallbackMessage(text, editParams);
} catch (editErr) {
const errStr = String(editErr);
if (errStr.includes("no text in the message")) {
try {
await deleteCallbackMessage();
} catch {}
await replyToCallbackChat(text, keyboard ? { reply_markup: keyboard, ...extra } : extra);
} else if (!errStr.includes("message is not modified")) {
throw editErr;
}
}
};
return {
editCallbackMessage,
clearCallbackButtons,
editCallbackButtons,
editCallbackMessageWithButtons,
deleteCallbackMessage,
replyToCallbackChat,
};
}
type ResolveQuestionParams = Parameters<typeof questionGatewayRuntime.resolveOption>[0];
type QuestionResolver = (
params: ResolveQuestionParams,
) => ReturnType<typeof questionGatewayRuntime.resolveOption>;
export async function handleTelegramQuestionCallback(params: {
callback: TelegramQuestionCallback;
cfg: ResolveQuestionParams["cfg"];
senderId: string;
feedback: (text: string, terminal: boolean) => Promise<unknown>;
resolveQuestion?: QuestionResolver;
}): Promise<void> {
let result: Awaited<ReturnType<QuestionResolver>>;
try {
result = await (params.resolveQuestion ?? questionGatewayRuntime.resolveOption)({
cfg: params.cfg,
questionId: params.callback.questionId,
optionIndex: params.callback.optionIndex,
senderId: params.senderId,
clientDisplayName: "Telegram question",
});
} catch (error) {
await params.feedback("Could not submit this answer.", false).catch(() => {});
throw error;
}
await params
.feedback(
result.status === "answered" ? "Answer submitted." : "This question was already answered.",
true,
)
.catch(() => {});
}
@@ -1,261 +0,0 @@
import {
resolveApprovalOverGateway,
type ApprovalResolveResult,
} from "openclaw/plugin-sdk/approval-gateway-runtime";
import type { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { TelegramApprovalCallback } from "./approval-callback-data.js";
import {
buildTelegramCanonicalApprovalTerminalText,
buildTelegramInvalidApprovalTerminalText,
buildTelegramLegacyApprovalTerminalText,
} from "./approval-terminal.js";
import type { TelegramCallbackMessageActions } from "./bot-handlers.callback-actions.runtime.js";
import {
isApprovalAlreadyResolvedError,
TelegramRetryableCallbackError,
} from "./bot-handlers.callback-errors.runtime.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import {
isTelegramExecApprovalApprover,
isTelegramExecApprovalAuthorizedSender,
} from "./exec-approvals.js";
type LegacyApprovalCallback = NonNullable<ReturnType<typeof parseExecApprovalCommandText>>;
export function createTelegramCallbackApprovalRuntime(params: {
accountId: RegisterTelegramHandlerParams["accountId"];
telegramDeps: RegisterTelegramHandlerParams["telegramDeps"];
runtimeCfg: OpenClawConfig;
senderId: string;
actions: TelegramCallbackMessageActions;
}) {
const { accountId, telegramDeps, runtimeCfg, senderId, actions } = params;
const { clearCallbackButtons, editCallbackMessage, replyToCallbackChat } = actions;
const resolveApprovalAuthorizations = () => {
const pluginApprovalAuthorizedSender = isTelegramExecApprovalApprover({
cfg: runtimeCfg,
accountId,
senderId,
});
const execApprovalAuthorizedSender = isTelegramExecApprovalAuthorizedSender({
cfg: runtimeCfg,
accountId,
senderId,
});
return { execApprovalAuthorizedSender, pluginApprovalAuthorizedSender };
};
const clearTerminalApprovalButtons = async () => {
try {
// First-answer-wins returns applied:false to losing surfaces. Their controls
// are stale too, so cleanup follows canonical terminal truth, not local authorship.
await clearCallbackButtons();
} catch (editErr) {
const errStr = String(editErr);
if (
errStr.includes("message is not modified") ||
errStr.includes("there is no text in the message to edit")
) {
return;
}
logVerbose(`telegram: failed to clear approval callback buttons: ${errStr}`);
}
};
const terminalizeApprovalMessage = async (text: string) => {
try {
await editCallbackMessage(text, { reply_markup: { inline_keyboard: [] } });
return;
} catch (editErr) {
const errStr = String(editErr);
const alreadyTerminal = errStr.includes("message is not modified");
if (!alreadyTerminal) {
logVerbose(`telegram: failed to render terminal approval receipt: ${errStr}`);
}
// Preserve the terminal state even when Telegram no longer permits a text edit.
await clearTerminalApprovalButtons();
if (alreadyTerminal) {
return;
}
}
try {
await replyToCallbackChat(text);
} catch (sendErr) {
logVerbose(`telegram: failed to send terminal approval receipt: ${String(sendErr)}`);
}
};
const resolveApproval = telegramDeps.resolveApproval ?? resolveApprovalOverGateway;
const resolveCanonicalApproval = async (
approvalCallback: TelegramApprovalCallback,
): Promise<ApprovalResolveResult> =>
(await resolveApproval({
cfg: runtimeCfg,
approvalId: approvalCallback.approvalId,
approvalKind: approvalCallback.approvalKind,
decision: approvalCallback.decision,
channel: "telegram",
accountId,
senderId,
})) as ApprovalResolveResult;
const terminalizeCanonicalApproval = async (
approvalCallback: TelegramApprovalCallback,
result: Awaited<ReturnType<typeof resolveCanonicalApproval>>,
) =>
await terminalizeApprovalMessage(
buildTelegramCanonicalApprovalTerminalText({
result,
fallbackApprovalId: approvalCallback.approvalId,
}),
);
const handleCanonical = async (approvalCallback: TelegramApprovalCallback): Promise<void> => {
const { execApprovalAuthorizedSender, pluginApprovalAuthorizedSender } =
resolveApprovalAuthorizations();
const authorizedApprovalSender =
approvalCallback.approvalKind === "plugin"
? pluginApprovalAuthorizedSender
: execApprovalAuthorizedSender || pluginApprovalAuthorizedSender;
if (!authorizedApprovalSender) {
logVerbose(
`Blocked telegram approval callback from ${senderId || "unknown"} (not authorized)`,
);
return;
}
try {
const result = await resolveCanonicalApproval(approvalCallback);
if (!result.applied) {
logVerbose(
`telegram: approval callback already resolved ${approvalCallback.approvalId} ` +
`status=${result.approval.status}`,
);
}
await terminalizeCanonicalApproval(approvalCallback, result);
} catch (resolveErr) {
logVerbose(
`telegram: failed to resolve approval callback ${approvalCallback.approvalId}: ${String(resolveErr)}`,
);
if (isApprovalNotFoundError(resolveErr) || isApprovalAlreadyResolvedError(resolveErr)) {
await terminalizeApprovalMessage(
buildTelegramLegacyApprovalTerminalText({
approvalId: approvalCallback.approvalId,
outcome: "no-longer-pending",
}),
);
return;
}
throw new TelegramRetryableCallbackError(resolveErr);
}
};
const handleMalformedReserved = async (): Promise<void> => {
const { execApprovalAuthorizedSender, pluginApprovalAuthorizedSender } =
resolveApprovalAuthorizations();
if (!execApprovalAuthorizedSender && !pluginApprovalAuthorizedSender) {
logVerbose(
`Blocked malformed telegram approval callback from ${senderId || "unknown"} (not authorized)`,
);
return;
}
logVerbose(`telegram: consumed malformed reserved approval callback from ${senderId}`);
await terminalizeApprovalMessage(buildTelegramInvalidApprovalTerminalText());
};
const handleLegacy = async (approvalCallback: LegacyApprovalCallback): Promise<void> => {
const { execApprovalAuthorizedSender, pluginApprovalAuthorizedSender } =
resolveApprovalAuthorizations();
const approvalKinds: Array<"exec" | "plugin"> = [];
if (execApprovalAuthorizedSender || pluginApprovalAuthorizedSender) {
approvalKinds.push("exec");
}
if (pluginApprovalAuthorizedSender) {
approvalKinds.push("plugin");
}
if (approvalKinds.length === 0) {
logVerbose(
`Blocked telegram approval callback from ${senderId || "unknown"} (not authorized)`,
);
return;
}
for (const approvalKind of approvalKinds) {
const canonicalCallback: TelegramApprovalCallback = {
type: "approval",
approvalId: approvalCallback.approvalId,
approvalKind,
decision: approvalCallback.decision,
};
try {
// Legacy callbacks lack an owner. Probe only adapters this sender may use.
await resolveApproval({
cfg: runtimeCfg,
approvalId: approvalCallback.approvalId,
decision: approvalCallback.decision,
channel: "telegram",
accountId,
senderId,
resolveMethod: approvalKind,
});
await terminalizeApprovalMessage(
buildTelegramLegacyApprovalTerminalText({
approvalId: approvalCallback.approvalId,
decision: approvalCallback.decision,
outcome: "resolved-here",
}),
);
return;
} catch (resolveErr) {
if (isApprovalNotFoundError(resolveErr)) {
continue;
}
if (isApprovalAlreadyResolvedError(resolveErr)) {
try {
const result = await resolveCanonicalApproval(canonicalCallback);
await terminalizeCanonicalApproval(canonicalCallback, result);
} catch (canonicalError) {
if (
!isApprovalNotFoundError(canonicalError) &&
!isApprovalAlreadyResolvedError(canonicalError)
) {
throw new TelegramRetryableCallbackError(canonicalError);
}
logVerbose(
`telegram: canonical approval lookup failed after stale legacy callback ` +
`${approvalCallback.approvalId}: ${String(canonicalError)}`,
);
await terminalizeApprovalMessage(
buildTelegramLegacyApprovalTerminalText({
approvalId: approvalCallback.approvalId,
outcome: "no-longer-pending",
}),
);
}
return;
}
logVerbose(
`telegram: failed to resolve approval callback ${approvalCallback.approvalId}: ${String(resolveErr)}`,
);
throw new TelegramRetryableCallbackError(resolveErr);
}
}
logVerbose(`telegram: approval callback not found ${approvalCallback.approvalId}`);
if (!pluginApprovalAuthorizedSender) {
return;
}
await terminalizeApprovalMessage(
buildTelegramLegacyApprovalTerminalText({
approvalId: approvalCallback.approvalId,
outcome: "no-longer-pending",
}),
);
};
return { handleCanonical, handleMalformedReserved, handleLegacy };
}
@@ -1,33 +0,0 @@
import {
isTelegramEditTargetMissingError,
isTelegramMessageHasNoTextError,
} from "./network-errors.js";
export class TelegramRetryableCallbackError extends Error {
public override readonly cause: unknown;
constructor(cause: unknown) {
super(String(cause));
this.cause = cause;
this.name = "TelegramRetryableCallbackError";
}
}
export const isPermanentTelegramCallbackEditError = (err: unknown): boolean =>
isTelegramEditTargetMissingError(err) || isTelegramMessageHasNoTextError(err);
export function isApprovalAlreadyResolvedError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const record = error as {
gatewayCode?: unknown;
details?: { reason?: unknown } | null;
};
const reason = record.details?.reason;
return (
record.gatewayCode === "APPROVAL_ALREADY_RESOLVED" ||
(record.gatewayCode === "INVALID_REQUEST" && reason === "APPROVAL_ALREADY_RESOLVED") ||
/approval already resolved/i.test(error.message)
);
}
@@ -1,355 +0,0 @@
import { randomUUID } from "node:crypto";
import { buildCommandsMessagePaginated } from "openclaw/plugin-sdk/command-status";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { applySessionModelSelection } from "openclaw/plugin-sdk/model-session-runtime";
import { formatModelsAvailableHeader } from "openclaw/plugin-sdk/models-provider-runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { getSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import {
resolveAgentDir,
resolveDefaultAgentId,
resolveDefaultModelForAgent,
} from "./bot-handlers.agent.runtime.js";
import type { TelegramCallbackMessageActions } from "./bot-handlers.callback-actions.runtime.js";
import { TelegramRetryableCallbackError } from "./bot-handlers.callback-errors.runtime.js";
import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import { resolveTelegramBotHasTopicsEnabled } from "./bot/helpers.js";
import type { TelegramContext } from "./bot/types.js";
import { buildCommandsPaginationKeyboard, buildTelegramModelsMenuButtons } from "./command-ui.js";
import {
buildModelsKeyboard,
buildProviderKeyboard,
calculateTotalPages,
getModelsPageSize,
parseModelCallbackData,
resolveModelSelection,
type ProviderInfo,
} from "./model-buttons.js";
import { buildInlineKeyboard } from "./send.js";
export async function handleTelegramModelCallback(params: {
data: string;
ctx: Pick<TelegramContext, "me">;
chatId: number;
isGroup: boolean;
isForum: boolean;
messageThreadId?: number;
resolvedThreadId?: number;
senderId: string;
runtimeCfg: OpenClawConfig;
telegramDeps: RegisterTelegramHandlerParams["telegramDeps"];
actions: TelegramCallbackMessageActions;
messageRuntime: TelegramHandlerMessageRuntime;
authorizeCallback: () => Promise<boolean>;
}): Promise<boolean> {
const {
data,
ctx,
chatId,
isGroup,
isForum,
messageThreadId,
resolvedThreadId,
senderId,
runtimeCfg,
telegramDeps,
actions,
messageRuntime,
authorizeCallback,
} = params;
const { editCallbackMessage, deleteCallbackMessage, replyToCallbackChat } = actions;
const paginationMatch = data.match(/^commands_page_(\d+|noop)(?::(.+))?$/);
if (paginationMatch) {
const pageValue = paginationMatch[1];
if (pageValue === "noop") {
return true;
}
const page = parseStrictPositiveInteger(pageValue);
if (page === undefined) {
return true;
}
const agentId = paginationMatch[2]?.trim() || resolveDefaultAgentId(runtimeCfg);
let result: ReturnType<typeof buildCommandsMessagePaginated>;
try {
const skillCommands = telegramDeps.listSkillCommandsForAgents({
cfg: runtimeCfg,
agentIds: [agentId],
});
result = buildCommandsMessagePaginated(runtimeCfg, skillCommands, {
page,
forcePaginatedList: true,
surface: "telegram",
});
} catch (err) {
throw new TelegramRetryableCallbackError(err);
}
const keyboard =
result.totalPages > 1
? buildInlineKeyboard(
buildCommandsPaginationKeyboard(result.currentPage, result.totalPages, agentId),
)
: undefined;
try {
await editCallbackMessage(result.text, keyboard ? { reply_markup: keyboard } : undefined);
} catch (editErr) {
if (!String(editErr).includes("message is not modified")) {
throw new TelegramRetryableCallbackError(editErr);
}
}
return true;
}
const modelCallback = parseModelCallbackData(data);
if (!modelCallback) {
return false;
}
if (!(await authorizeCallback())) {
logVerbose(
`Blocked telegram model callback from ${senderId || "unknown"} (not authorized for /models)`,
);
return true;
}
let sessionState: ReturnType<TelegramHandlerMessageRuntime["resolveTelegramSessionState"]>;
let modelData: Awaited<ReturnType<typeof telegramDeps.buildModelsProviderData>>;
try {
sessionState = messageRuntime.resolveTelegramSessionState({
chatId,
isGroup,
isForum,
messageThreadId,
resolvedThreadId,
botHasTopicsEnabled: resolveTelegramBotHasTopicsEnabled(ctx.me),
senderId,
runtimeCfg,
});
modelData = await telegramDeps.buildModelsProviderData(runtimeCfg, sessionState.agentId);
} catch (err) {
throw new TelegramRetryableCallbackError(err);
}
const { byProvider, providers, modelNames, resolvedDefault: activeResolvedDefault } = modelData;
const editMessageWithButtons = async (
text: string,
buttons: ReturnType<typeof buildProviderKeyboard>,
extra?: { parse_mode?: "HTML" | "Markdown" | "MarkdownV2" },
) => {
const keyboard = buildInlineKeyboard(buttons);
const editParams = keyboard ? { reply_markup: keyboard, ...extra } : extra;
try {
await editCallbackMessage(text, editParams);
} catch (editErr) {
const errStr = String(editErr);
if (errStr.includes("no text in the message")) {
try {
await deleteCallbackMessage();
} catch {}
await replyToCallbackChat(text, keyboard ? { reply_markup: keyboard, ...extra } : extra);
} else if (!errStr.includes("message is not modified")) {
throw editErr;
}
}
};
if (modelCallback.type === "providers" || modelCallback.type === "back") {
if (providers.length === 0) {
try {
await editMessageWithButtons("No providers available.", []);
} catch (err) {
throw new TelegramRetryableCallbackError(err);
}
return true;
}
const providerInfos: ProviderInfo[] = providers.map((provider) => ({
id: provider,
count: byProvider.get(provider)?.size ?? 0,
}));
try {
await editMessageWithButtons(
"Select a provider:",
buildTelegramModelsMenuButtons({ providers: providerInfos }),
);
} catch (err) {
throw new TelegramRetryableCallbackError(err);
}
return true;
}
if (modelCallback.type === "list") {
const { provider, page } = modelCallback;
const modelSet = byProvider.get(provider);
if (!modelSet || modelSet.size === 0) {
const providerInfos: ProviderInfo[] = providers.map((providerId) => ({
id: providerId,
count: byProvider.get(providerId)?.size ?? 0,
}));
try {
await editMessageWithButtons(
`Unknown provider: ${provider}\n\nSelect a provider:`,
buildTelegramModelsMenuButtons({ providers: providerInfos }),
);
} catch (err) {
throw new TelegramRetryableCallbackError(err);
}
return true;
}
const models = [...modelSet].toSorted((left, right) => left.localeCompare(right));
const pageSize = getModelsPageSize();
const totalPages = calculateTotalPages(models.length, pageSize);
const safePage = Math.max(1, Math.min(page, totalPages));
const currentModel =
sessionState.model || `${activeResolvedDefault.provider}/${activeResolvedDefault.model}`;
const buttons = buildModelsKeyboard({
provider,
models,
currentModel,
currentPage: safePage,
totalPages,
pageSize,
modelNames,
});
const text = formatModelsAvailableHeader({
provider,
total: models.length,
cfg: runtimeCfg,
agentDir: resolveAgentDir(runtimeCfg, sessionState.agentId),
sessionEntry: sessionState.sessionEntry,
});
try {
await editMessageWithButtons(text, buttons);
} catch (err) {
throw new TelegramRetryableCallbackError(err);
}
return true;
}
if (modelCallback.type !== "select") {
return true;
}
const selection = resolveModelSelection({ callback: modelCallback, providers, byProvider });
if (selection.kind !== "resolved") {
const providerInfos: ProviderInfo[] = providers.map((provider) => ({
id: provider,
count: byProvider.get(provider)?.size ?? 0,
}));
try {
await editMessageWithButtons(
`Could not resolve model "${selection.model}".\n\nSelect a provider:`,
buildTelegramModelsMenuButtons({ providers: providerInfos }),
);
} catch (err) {
throw new TelegramRetryableCallbackError(err);
}
return true;
}
if (!byProvider.get(selection.provider)?.has(selection.model)) {
try {
await editMessageWithButtons(
`❌ Model "${selection.provider}/${selection.model}" is not allowed.`,
[],
);
} catch (err) {
throw new TelegramRetryableCallbackError(err);
}
return true;
}
try {
const storePath = telegramDeps.resolveStorePath(runtimeCfg.session?.store, {
agentId: sessionState.agentId,
});
const resolvedDefault = resolveDefaultModelForAgent({
cfg: runtimeCfg,
agentId: sessionState.agentId,
});
const isDefaultSelection =
selection.provider === resolvedDefault.provider && selection.model === resolvedDefault.model;
const persistedSessionEntry =
sessionState.sessionEntry ??
telegramDeps.getSessionEntry?.({ storePath, sessionKey: sessionState.sessionKey }) ??
getSessionEntry({ storePath, sessionKey: sessionState.sessionKey });
const sessionEntryMissing = persistedSessionEntry === undefined;
const sessionEntry = persistedSessionEntry ?? {
sessionId: randomUUID(),
updatedAt: Date.now(),
};
const previousAuthProfileId = sessionEntry.authProfileOverride?.trim();
const sessionStore = { [sessionState.sessionKey]: sessionEntry };
const modelCatalog = [...byProvider.entries()].flatMap(([provider, models]) =>
[...models].map((model) => ({ provider, id: model, name: model })),
);
const currentModelRef = sessionState.model?.trim();
const currentModelSeparator = currentModelRef?.indexOf("/") ?? -1;
const currentProvider =
currentModelRef && currentModelSeparator > 0
? currentModelRef.slice(0, currentModelSeparator)
: resolvedDefault.provider;
const currentModel =
currentModelRef && currentModelSeparator > 0
? currentModelRef.slice(currentModelSeparator + 1)
: resolvedDefault.model;
let applied: Awaited<ReturnType<typeof applySessionModelSelection>>;
try {
applied = await applySessionModelSelection({
cfg: runtimeCfg,
agentId: sessionState.agentId,
sessionKey: sessionState.sessionKey,
storePath,
sessionEntry,
sessionStore,
allowCreate: sessionEntryMissing,
defaultProvider: resolvedDefault.provider,
defaultModel: resolvedDefault.model,
currentProvider,
currentModel,
allowedModelKeys: new Set(modelCatalog.map((entry) => `${entry.provider}/${entry.id}`)),
modelCatalog,
canPersistStickyModelSelection: false,
request: {
provider: selection.provider,
model: selection.model,
isDefault: isDefaultSelection,
runtime: { kind: "unchanged" },
},
markLiveSwitchPending: true,
});
} catch (err) {
throw new TelegramRetryableCallbackError(err);
}
if (applied.status !== "applied") {
await editMessageWithButtons(`${applied.message}`, []);
return true;
}
const defaultAuthProfileNotice =
isDefaultSelection && previousAuthProfileId
? sessionStore[sessionState.sessionKey]?.authProfileOverride?.trim() ===
previousAuthProfileId
? "Compatible auth profile retained."
: "Incompatible auth profile cleared."
: undefined;
const escapeHtml = (text: string) =>
text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const actionText = isDefaultSelection
? "reset to default"
: `changed to <b>${escapeHtml(selection.provider)}/${escapeHtml(selection.model)}</b>`;
const runtimeText =
applied.runtimeChange?.kind === "clear"
? "Runtime reset to configured policy."
: "Runtime unchanged.";
const scopeText = isDefaultSelection
? `Session model selection cleared.${defaultAuthProfileNotice ? ` ${defaultAuthProfileNotice}` : ""} ${runtimeText} New replies use the agent's configured default.`
: `Session-only model selection. ${runtimeText} Use /model ${escapeHtml(selection.provider)}/${escapeHtml(selection.model)} --runtime &lt;runtime&gt; -s to switch harnesses. The agent default in openclaw.json is unchanged. This chat keeps the model selection across /new and /reset; use /model default -s to clear the session model selection.`;
await editMessageWithButtons(`✅ Model ${actionText}\n\n${scopeText}`, [], {
parse_mode: "HTML",
});
} catch (err) {
if (err instanceof TelegramRetryableCallbackError) {
throw err;
}
await editMessageWithButtons(`❌ Failed to change model: ${String(err)}`, []);
}
return true;
}
@@ -1,6 +1,6 @@
// Telegram question callback feedback tests.
import { describe, expect, it, vi } from "vitest";
import { handleTelegramQuestionCallback } from "./bot-handlers.callback-questions.runtime.js";
import { handleTelegramQuestionCallback } from "./bot-handlers.callback-actions.js";
const callback = {
questionId: "ask_0123456789abcdef0123456789abcdef",
@@ -1,36 +0,0 @@
// Telegram ask_user callback resolution and toast feedback.
import { questionGatewayRuntime } from "openclaw/plugin-sdk/question-gateway-runtime";
import type { TelegramQuestionCallback } from "./question-callback-data.js";
type ResolveQuestionParams = Parameters<typeof questionGatewayRuntime.resolveOption>[0];
type QuestionResolver = (
params: ResolveQuestionParams,
) => ReturnType<typeof questionGatewayRuntime.resolveOption>;
export async function handleTelegramQuestionCallback(params: {
callback: TelegramQuestionCallback;
cfg: ResolveQuestionParams["cfg"];
senderId: string;
feedback: (text: string, terminal: boolean) => Promise<unknown>;
resolveQuestion?: QuestionResolver;
}): Promise<void> {
let result: Awaited<ReturnType<QuestionResolver>>;
try {
result = await (params.resolveQuestion ?? questionGatewayRuntime.resolveOption)({
cfg: params.cfg,
questionId: params.callback.questionId,
optionIndex: params.callback.optionIndex,
senderId: params.senderId,
clientDisplayName: "Telegram question",
});
} catch (error) {
await params.feedback("Could not submit this answer.", false).catch(() => {});
throw error;
}
await params
.feedback(
result.status === "answered" ? "Answer submitted." : "This question was already answered.",
true,
)
.catch(() => {});
}
@@ -1,17 +1,29 @@
import type { CallbackQuery, Message } from "grammy/types";
import {
resolveApprovalOverGateway,
type ApprovalResolveResult,
} from "openclaw/plugin-sdk/approval-gateway-runtime";
import type { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
buildPluginBindingResolvedText,
parsePluginBindingApprovalCustomId,
resolvePluginConversationBindingApproval,
} from "openclaw/plugin-sdk/conversation-runtime";
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
import { logVerbose, sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
import type { TelegramApprovalCallback } from "./approval-callback-data.js";
import {
buildTelegramCanonicalApprovalTerminalText,
buildTelegramInvalidApprovalTerminalText,
buildTelegramLegacyApprovalTerminalText,
} from "./approval-terminal.js";
import type {
TelegramCallbackButton,
TelegramCallbackMessageActions,
} from "./bot-handlers.callback-actions.runtime.js";
import { TelegramRetryableCallbackError } from "./bot-handlers.callback-errors.runtime.js";
import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
} from "./bot-handlers.callback-actions.js";
import type { TelegramMessagePipeline } from "./bot-handlers.message-pipeline.js";
import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js";
import {
createTelegramSpooledReplayDeferredParticipant,
getTelegramSpooledReplayDeferredParticipant,
@@ -20,9 +32,286 @@ import {
} from "./bot-processing-outcome.js";
import { withResolvedTelegramForumFlag } from "./bot/helpers.js";
import type { TelegramContext } from "./bot/types.js";
import {
isTelegramExecApprovalApprover,
isTelegramExecApprovalAuthorizedSender,
} from "./exec-approvals.js";
import { dispatchTelegramPluginInteractiveHandler } from "./interactive-dispatch.js";
import {
isTelegramEditTargetMissingError,
isTelegramMessageHasNoTextError,
} from "./network-errors.js";
import { buildInlineKeyboard } from "./send.js";
export type TelegramCallbackMessageRuntime = Pick<
TelegramMessagePipeline,
| "buildSyntheticTextMessage"
| "buildSyntheticContext"
| "buildFailedProcessingResult"
| "processMessageWithReplyChain"
| "resolveTelegramSessionState"
>;
export class TelegramRetryableCallbackError extends Error {
public override readonly cause: unknown;
constructor(cause: unknown) {
super(String(cause));
this.cause = cause;
this.name = "TelegramRetryableCallbackError";
}
}
export const isPermanentTelegramCallbackEditError = (err: unknown): boolean =>
isTelegramEditTargetMissingError(err) || isTelegramMessageHasNoTextError(err);
function isApprovalAlreadyResolvedError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const record = error as {
gatewayCode?: unknown;
details?: { reason?: unknown } | null;
};
const reason = record.details?.reason;
return (
record.gatewayCode === "APPROVAL_ALREADY_RESOLVED" ||
(record.gatewayCode === "INVALID_REQUEST" && reason === "APPROVAL_ALREADY_RESOLVED") ||
/approval already resolved/i.test(error.message)
);
}
type LegacyApprovalCallback = NonNullable<ReturnType<typeof parseExecApprovalCommandText>>;
export function createTelegramCallbackApprovalRuntime(params: {
accountId: RegisterTelegramHandlerParams["accountId"];
telegramDeps: RegisterTelegramHandlerParams["telegramDeps"];
runtimeCfg: OpenClawConfig;
senderId: string;
actions: TelegramCallbackMessageActions;
}) {
const { accountId, telegramDeps, runtimeCfg, senderId, actions } = params;
const { clearCallbackButtons, editCallbackMessage, replyToCallbackChat } = actions;
const resolveApprovalAuthorizations = () => {
const pluginApprovalAuthorizedSender = isTelegramExecApprovalApprover({
cfg: runtimeCfg,
accountId,
senderId,
});
const execApprovalAuthorizedSender = isTelegramExecApprovalAuthorizedSender({
cfg: runtimeCfg,
accountId,
senderId,
});
return { execApprovalAuthorizedSender, pluginApprovalAuthorizedSender };
};
const clearTerminalApprovalButtons = async () => {
try {
// First-answer-wins returns applied:false to losing surfaces. Their controls
// are stale too, so cleanup follows canonical terminal truth, not local authorship.
await clearCallbackButtons();
} catch (editErr) {
const errStr = String(editErr);
if (
errStr.includes("message is not modified") ||
errStr.includes("there is no text in the message to edit")
) {
return;
}
logVerbose(`telegram: failed to clear approval callback buttons: ${errStr}`);
}
};
const terminalizeApprovalMessage = async (text: string) => {
try {
await editCallbackMessage(text, { reply_markup: { inline_keyboard: [] } });
return;
} catch (editErr) {
const errStr = String(editErr);
const alreadyTerminal = errStr.includes("message is not modified");
if (!alreadyTerminal) {
logVerbose(`telegram: failed to render terminal approval receipt: ${errStr}`);
}
// Preserve the terminal state even when Telegram no longer permits a text edit.
await clearTerminalApprovalButtons();
if (alreadyTerminal) {
return;
}
}
try {
await replyToCallbackChat(text);
} catch (sendErr) {
logVerbose(`telegram: failed to send terminal approval receipt: ${String(sendErr)}`);
}
};
const terminalizeLegacyApproval = async (
receipt: Parameters<typeof buildTelegramLegacyApprovalTerminalText>[0],
) => await terminalizeApprovalMessage(buildTelegramLegacyApprovalTerminalText(receipt));
const resolveApproval = telegramDeps.resolveApproval ?? resolveApprovalOverGateway;
const resolveCanonicalApproval = async (
approvalCallback: TelegramApprovalCallback,
): Promise<ApprovalResolveResult> =>
(await resolveApproval({
cfg: runtimeCfg,
approvalId: approvalCallback.approvalId,
approvalKind: approvalCallback.approvalKind,
decision: approvalCallback.decision,
channel: "telegram",
accountId,
senderId,
})) as ApprovalResolveResult;
const terminalizeCanonicalApproval = async (
approvalCallback: TelegramApprovalCallback,
result: Awaited<ReturnType<typeof resolveCanonicalApproval>>,
) =>
await terminalizeApprovalMessage(
buildTelegramCanonicalApprovalTerminalText({
result,
fallbackApprovalId: approvalCallback.approvalId,
}),
);
const handleCanonical = async (approvalCallback: TelegramApprovalCallback): Promise<void> => {
const { execApprovalAuthorizedSender, pluginApprovalAuthorizedSender } =
resolveApprovalAuthorizations();
const authorizedApprovalSender =
approvalCallback.approvalKind === "plugin"
? pluginApprovalAuthorizedSender
: execApprovalAuthorizedSender || pluginApprovalAuthorizedSender;
if (!authorizedApprovalSender) {
logVerbose(
`Blocked telegram approval callback from ${senderId || "unknown"} (not authorized)`,
);
return;
}
try {
const result = await resolveCanonicalApproval(approvalCallback);
if (!result.applied) {
logVerbose(
`telegram: approval callback already resolved ${approvalCallback.approvalId} ` +
`status=${result.approval.status}`,
);
}
await terminalizeCanonicalApproval(approvalCallback, result);
} catch (resolveErr) {
logVerbose(
`telegram: failed to resolve approval callback ${approvalCallback.approvalId}: ${String(resolveErr)}`,
);
if (isApprovalNotFoundError(resolveErr) || isApprovalAlreadyResolvedError(resolveErr)) {
await terminalizeLegacyApproval({
approvalId: approvalCallback.approvalId,
outcome: "no-longer-pending",
});
return;
}
throw new TelegramRetryableCallbackError(resolveErr);
}
};
const handleMalformedReserved = async (): Promise<void> => {
const { execApprovalAuthorizedSender, pluginApprovalAuthorizedSender } =
resolveApprovalAuthorizations();
if (!execApprovalAuthorizedSender && !pluginApprovalAuthorizedSender) {
logVerbose(
`Blocked malformed telegram approval callback from ${senderId || "unknown"} (not authorized)`,
);
return;
}
logVerbose(`telegram: consumed malformed reserved approval callback from ${senderId}`);
await terminalizeApprovalMessage(buildTelegramInvalidApprovalTerminalText());
};
const handleLegacy = async (approvalCallback: LegacyApprovalCallback): Promise<void> => {
const { execApprovalAuthorizedSender, pluginApprovalAuthorizedSender } =
resolveApprovalAuthorizations();
const approvalKinds: Array<"exec" | "plugin"> = [];
if (execApprovalAuthorizedSender || pluginApprovalAuthorizedSender) {
approvalKinds.push("exec");
}
if (pluginApprovalAuthorizedSender) {
approvalKinds.push("plugin");
}
if (approvalKinds.length === 0) {
logVerbose(
`Blocked telegram approval callback from ${senderId || "unknown"} (not authorized)`,
);
return;
}
for (const approvalKind of approvalKinds) {
const canonicalCallback: TelegramApprovalCallback = {
type: "approval",
approvalId: approvalCallback.approvalId,
approvalKind,
decision: approvalCallback.decision,
};
try {
// Legacy callbacks lack an owner. Probe only adapters this sender may use.
await resolveApproval({
cfg: runtimeCfg,
approvalId: approvalCallback.approvalId,
decision: approvalCallback.decision,
channel: "telegram",
accountId,
senderId,
resolveMethod: approvalKind,
});
await terminalizeLegacyApproval({
approvalId: approvalCallback.approvalId,
decision: approvalCallback.decision,
outcome: "resolved-here",
});
return;
} catch (resolveErr) {
if (isApprovalNotFoundError(resolveErr)) {
continue;
}
if (isApprovalAlreadyResolvedError(resolveErr)) {
try {
const result = await resolveCanonicalApproval(canonicalCallback);
await terminalizeCanonicalApproval(canonicalCallback, result);
} catch (canonicalError) {
if (
!isApprovalNotFoundError(canonicalError) &&
!isApprovalAlreadyResolvedError(canonicalError)
) {
throw new TelegramRetryableCallbackError(canonicalError);
}
logVerbose(
`telegram: canonical approval lookup failed after stale legacy callback ` +
`${approvalCallback.approvalId}: ${String(canonicalError)}`,
);
await terminalizeLegacyApproval({
approvalId: approvalCallback.approvalId,
outcome: "no-longer-pending",
});
}
return;
}
logVerbose(
`telegram: failed to resolve approval callback ${approvalCallback.approvalId}: ${String(resolveErr)}`,
);
throw new TelegramRetryableCallbackError(resolveErr);
}
}
logVerbose(`telegram: approval callback not found ${approvalCallback.approvalId}`);
if (!pluginApprovalAuthorizedSender) {
return;
}
await terminalizeLegacyApproval({
approvalId: approvalCallback.approvalId,
outcome: "no-longer-pending",
});
};
return { handleCanonical, handleMalformedReserved, handleLegacy };
}
const MULTI_SELECT_PREFIX = "OC_MULTI|";
const MULTI_SELECT_TOGGLE_PREFIX = `${MULTI_SELECT_PREFIX}toggle|`;
const SELECT_PREFIX = "OC_SELECT|";
@@ -157,11 +446,9 @@ export async function handleTelegramInteractiveCallback(params: {
senderUsername: string;
isGroup: boolean;
isForum: boolean;
storeAllowFrom: Parameters<
TelegramHandlerMessageRuntime["processMessageWithReplyChain"]
>[0]["storeAllowFrom"];
storeAllowFrom: string[];
actions: TelegramCallbackMessageActions;
messageRuntime: TelegramHandlerMessageRuntime;
messageRuntime: TelegramCallbackMessageRuntime;
authorizeCallback: () => Promise<boolean>;
}): Promise<boolean> {
const {
@@ -1,39 +1,66 @@
// Telegram callback-query routing across approvals, plugin actions, selects, commands, and models.
import { randomUUID } from "node:crypto";
import type { Context } from "grammy";
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
import { buildCommandsMessagePaginated } from "openclaw/plugin-sdk/command-status";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { applySessionModelSelection } from "openclaw/plugin-sdk/model-session-runtime";
import { formatModelsAvailableHeader } from "openclaw/plugin-sdk/models-provider-runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { getSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import {
hasTelegramApprovalCallbackPrefix,
parseTelegramApprovalCallbackData,
} from "./approval-callback-data.js";
import {
resolveAgentDir,
resolveDefaultAgentId,
resolveDefaultModelForAgent,
} from "./bot-handlers.agent.runtime.js";
import {
createTelegramCallbackMessageActions,
handleTelegramQuestionCallback,
type TelegramCallbackMessageActions,
} from "./bot-handlers.callback-actions.js";
import {
createTelegramCallbackApprovalRuntime,
handleTelegramInteractiveCallback,
isPermanentTelegramCallbackEditError,
type TelegramCallbackMessageRuntime,
TelegramRetryableCallbackError,
} from "./bot-handlers.callback-router-controls.js";
import type {
TelegramEventAuthorizationMode,
TelegramHandlerAuthorizationRuntime,
} from "./bot-handlers.authorization.runtime.js";
import { createTelegramCallbackMessageActions } from "./bot-handlers.callback-actions.runtime.js";
import { createTelegramCallbackApprovalRuntime } from "./bot-handlers.callback-approvals.runtime.js";
import {
isPermanentTelegramCallbackEditError,
TelegramRetryableCallbackError,
} from "./bot-handlers.callback-errors.runtime.js";
import { handleTelegramInteractiveCallback } from "./bot-handlers.callback-interactions.runtime.js";
import { handleTelegramModelCallback } from "./bot-handlers.callback-model.runtime.js";
import { handleTelegramQuestionCallback } from "./bot-handlers.callback-questions.runtime.js";
import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js";
TelegramHandlerAuthorization,
} from "./bot-handlers.inbound-authorization.js";
import type {
RegisterTelegramHandlerParams,
TelegramCallbackRouter,
} from "./bot-handlers.types.js";
import { parseTelegramNativeCommandCallbackData } from "./bot-native-commands.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import {
isTelegramSpooledReplayUpdate,
recordTelegramMessageProcessingResult,
} from "./bot-processing-outcome.js";
import {
resolveTelegramForumFlag,
resolveTelegramBotHasTopicsEnabled,
resolveTelegramMessageThreadSpec,
withResolvedTelegramForumFlag,
} from "./bot/helpers.js";
import type { TelegramGetChat } from "./bot/types.js";
import type { TelegramContext, TelegramGetChat } from "./bot/types.js";
import { getTelegramCallbackQueryAnswerPromise } from "./callback-query-answer-state.js";
import { buildCommandsPaginationKeyboard, buildTelegramModelsMenuButtons } from "./command-ui.js";
import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js";
import {
buildModelsKeyboard,
calculateTotalPages,
getModelsPageSize,
parseModelCallbackData,
resolveModelSelection,
type ProviderInfo,
} from "./model-buttons.js";
import {
hasTelegramOpaqueCallbackPrefix,
parseTelegramOpaqueCallbackData,
@@ -43,19 +70,24 @@ import {
hasTelegramQuestionCallbackPrefix,
parseTelegramQuestionCallbackData,
} from "./question-callback-data.js";
import { buildInlineKeyboard } from "./send.js";
export function registerTelegramCallbackQueryHandler(
{
export function createTelegramCallbackRouter({
params: {
accountId,
bot,
runtime,
telegramDeps,
shouldSkipUpdate,
nativeCommandCallbackDispatcher,
}: RegisterTelegramHandlerParams,
messageRuntime: TelegramHandlerMessageRuntime,
authorizationRuntime: TelegramHandlerAuthorizationRuntime,
) {
},
message: messageRuntime,
authorization: authorizationRuntime,
}: {
params: RegisterTelegramHandlerParams;
message: TelegramCallbackMessageRuntime;
authorization: TelegramHandlerAuthorization;
}): TelegramCallbackRouter {
const { buildSyntheticTextMessage, buildSyntheticContext, processMessageWithReplyChain } =
messageRuntime;
const {
@@ -65,14 +97,13 @@ export function registerTelegramCallbackQueryHandler(
} = authorizationRuntime;
const getChat: TelegramGetChat = bot.api.getChat.bind(bot.api);
bot.on("callback_query", async (ctx) => {
const handleCallback = async (ctx: Context) => {
const callback = ctx.callbackQuery;
if (!callback) {
return;
}
let callbackAnswered = false;
const answerCallbackQuery = async (text?: string) => {
// Callback answers prevent Telegram retries while the routed action runs.
await withTelegramApiErrorLogging({
operation: "answerCallbackQuery",
runtime,
@@ -371,5 +402,292 @@ export function registerTelegramCallbackQueryHandler(
await answerCallbackQuery();
}
}
});
};
return {
route: async (ctx) => {
if (!ctx.callbackQuery) {
return { kind: "ignored" };
}
await handleCallback(ctx);
return { kind: "handled" };
},
};
}
async function handleTelegramModelCallback(params: {
data: string;
ctx: Pick<TelegramContext, "me">;
chatId: number;
isGroup: boolean;
isForum: boolean;
messageThreadId?: number;
resolvedThreadId?: number;
senderId: string;
runtimeCfg: OpenClawConfig;
telegramDeps: RegisterTelegramHandlerParams["telegramDeps"];
actions: TelegramCallbackMessageActions;
messageRuntime: TelegramCallbackMessageRuntime;
authorizeCallback: () => Promise<boolean>;
}): Promise<boolean> {
const {
data,
ctx,
chatId,
isGroup,
isForum,
messageThreadId,
resolvedThreadId,
senderId,
runtimeCfg,
telegramDeps,
actions,
messageRuntime,
authorizeCallback,
} = params;
const { editCallbackMessage, editCallbackMessageWithButtons: editMessageWithButtons } = actions;
const retryModelAction = async <T>(action: () => Promise<T>): Promise<T> => {
try {
return await action();
} catch (error) {
throw new TelegramRetryableCallbackError(error);
}
};
const paginationMatch = data.match(/^commands_page_(\d+|noop)(?::(.+))?$/);
if (paginationMatch) {
const pageValue = paginationMatch[1];
if (pageValue === "noop") {
return true;
}
const page = parseStrictPositiveInteger(pageValue);
if (page === undefined) {
return true;
}
const agentId = paginationMatch[2]?.trim() || resolveDefaultAgentId(runtimeCfg);
const result = await retryModelAction(async () => {
const skillCommands = telegramDeps.listSkillCommandsForAgents({
cfg: runtimeCfg,
agentIds: [agentId],
});
return buildCommandsMessagePaginated(runtimeCfg, skillCommands, {
page,
forcePaginatedList: true,
surface: "telegram",
});
});
const keyboard =
result.totalPages > 1
? buildInlineKeyboard(
buildCommandsPaginationKeyboard(result.currentPage, result.totalPages, agentId),
)
: undefined;
try {
await editCallbackMessage(result.text, keyboard ? { reply_markup: keyboard } : undefined);
} catch (editErr) {
if (!String(editErr).includes("message is not modified")) {
throw new TelegramRetryableCallbackError(editErr);
}
}
return true;
}
const modelCallback = parseModelCallbackData(data);
if (!modelCallback) {
return false;
}
if (!(await authorizeCallback())) {
logVerbose(
`Blocked telegram model callback from ${senderId || "unknown"} (not authorized for /models)`,
);
return true;
}
const { sessionState, modelData } = await retryModelAction(async () => {
const session = messageRuntime.resolveTelegramSessionState({
chatId,
isGroup,
isForum,
messageThreadId,
resolvedThreadId,
botHasTopicsEnabled: resolveTelegramBotHasTopicsEnabled(ctx.me),
senderId,
runtimeCfg,
});
const providerData = await telegramDeps.buildModelsProviderData(runtimeCfg, session.agentId);
return { sessionState: session, modelData: providerData };
});
const { byProvider, providers, modelNames, resolvedDefault: activeResolvedDefault } = modelData;
const providerInfos: ProviderInfo[] = providers.map((provider) => ({
id: provider,
count: byProvider.get(provider)?.size ?? 0,
}));
if (modelCallback.type === "providers" || modelCallback.type === "back") {
if (providers.length === 0) {
await retryModelAction(() => editMessageWithButtons("No providers available.", []));
return true;
}
await retryModelAction(() =>
editMessageWithButtons(
"Select a provider:",
buildTelegramModelsMenuButtons({ providers: providerInfos }),
),
);
return true;
}
if (modelCallback.type === "list") {
const { provider, page } = modelCallback;
const modelSet = byProvider.get(provider);
if (!modelSet || modelSet.size === 0) {
await retryModelAction(() =>
editMessageWithButtons(
`Unknown provider: ${provider}\n\nSelect a provider:`,
buildTelegramModelsMenuButtons({ providers: providerInfos }),
),
);
return true;
}
const models = [...modelSet].toSorted((left, right) => left.localeCompare(right));
const pageSize = getModelsPageSize();
const totalPages = calculateTotalPages(models.length, pageSize);
const safePage = Math.max(1, Math.min(page, totalPages));
const currentModel =
sessionState.model || `${activeResolvedDefault.provider}/${activeResolvedDefault.model}`;
const buttons = buildModelsKeyboard({
provider,
models,
currentModel,
currentPage: safePage,
totalPages,
pageSize,
modelNames,
});
const text = formatModelsAvailableHeader({
provider,
total: models.length,
cfg: runtimeCfg,
agentDir: resolveAgentDir(runtimeCfg, sessionState.agentId),
sessionEntry: sessionState.sessionEntry,
});
await retryModelAction(() => editMessageWithButtons(text, buttons));
return true;
}
if (modelCallback.type !== "select") {
return true;
}
const selection = resolveModelSelection({ callback: modelCallback, providers, byProvider });
if (selection.kind !== "resolved") {
await retryModelAction(() =>
editMessageWithButtons(
`Could not resolve model "${selection.model}".\n\nSelect a provider:`,
buildTelegramModelsMenuButtons({ providers: providerInfos }),
),
);
return true;
}
if (!byProvider.get(selection.provider)?.has(selection.model)) {
await retryModelAction(() =>
editMessageWithButtons(
`❌ Model "${selection.provider}/${selection.model}" is not allowed.`,
[],
),
);
return true;
}
try {
const storePath = telegramDeps.resolveStorePath(runtimeCfg.session?.store, {
agentId: sessionState.agentId,
});
const resolvedDefault = resolveDefaultModelForAgent({
cfg: runtimeCfg,
agentId: sessionState.agentId,
});
const isDefaultSelection =
selection.provider === resolvedDefault.provider && selection.model === resolvedDefault.model;
const persistedSessionEntry =
sessionState.sessionEntry ??
telegramDeps.getSessionEntry?.({ storePath, sessionKey: sessionState.sessionKey }) ??
getSessionEntry({ storePath, sessionKey: sessionState.sessionKey });
const sessionEntryMissing = persistedSessionEntry === undefined;
const sessionEntry = persistedSessionEntry ?? {
sessionId: randomUUID(),
updatedAt: Date.now(),
};
const previousAuthProfileId = sessionEntry.authProfileOverride?.trim();
const sessionStore = { [sessionState.sessionKey]: sessionEntry };
const modelCatalog = [...byProvider.entries()].flatMap(([provider, models]) =>
[...models].map((model) => ({ provider, id: model, name: model })),
);
const currentModelRef = sessionState.model?.trim();
const currentModelSeparator = currentModelRef?.indexOf("/") ?? -1;
const currentProvider =
currentModelRef && currentModelSeparator > 0
? currentModelRef.slice(0, currentModelSeparator)
: resolvedDefault.provider;
const currentModel =
currentModelRef && currentModelSeparator > 0
? currentModelRef.slice(currentModelSeparator + 1)
: resolvedDefault.model;
const applied = await retryModelAction(() =>
applySessionModelSelection({
cfg: runtimeCfg,
agentId: sessionState.agentId,
sessionKey: sessionState.sessionKey,
storePath,
sessionEntry,
sessionStore,
allowCreate: sessionEntryMissing,
defaultProvider: resolvedDefault.provider,
defaultModel: resolvedDefault.model,
currentProvider,
currentModel,
allowedModelKeys: new Set(modelCatalog.map((entry) => `${entry.provider}/${entry.id}`)),
modelCatalog,
canPersistStickyModelSelection: false,
request: {
provider: selection.provider,
model: selection.model,
isDefault: isDefaultSelection,
runtime: { kind: "unchanged" },
},
markLiveSwitchPending: true,
}),
);
if (applied.status !== "applied") {
await editMessageWithButtons(`${applied.message}`, []);
return true;
}
const defaultAuthProfileNotice =
isDefaultSelection && previousAuthProfileId
? sessionStore[sessionState.sessionKey]?.authProfileOverride?.trim() ===
previousAuthProfileId
? "Compatible auth profile retained."
: "Incompatible auth profile cleared."
: undefined;
const escapeHtml = (text: string) =>
text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const actionText = isDefaultSelection
? "reset to default"
: `changed to <b>${escapeHtml(selection.provider)}/${escapeHtml(selection.model)}</b>`;
const runtimeText =
applied.runtimeChange?.kind === "clear"
? "Runtime reset to configured policy."
: "Runtime unchanged.";
const scopeText = isDefaultSelection
? `Session model selection cleared.${defaultAuthProfileNotice ? ` ${defaultAuthProfileNotice}` : ""} ${runtimeText} New replies use the agent's configured default.`
: `Session-only model selection. ${runtimeText} Use /model ${escapeHtml(selection.provider)}/${escapeHtml(selection.model)} --runtime &lt;runtime&gt; -s to switch harnesses. The agent default in openclaw.json is unchanged. This chat keeps the model selection across /new and /reset; use /model default -s to clear the session model selection.`;
await editMessageWithButtons(`✅ Model ${actionText}\n\n${scopeText}`, [], {
parse_mode: "HTML",
});
} catch (err) {
if (err instanceof TelegramRetryableCallbackError) {
throw err;
}
await editMessageWithButtons(`❌ Failed to change model: ${String(err)}`, []);
}
return true;
}
@@ -0,0 +1,437 @@
import type { ChatMember, ReactionTypeEmoji } from "grammy/types";
import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-helpers";
import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
import { resolveTelegramAccount } from "./accounts.js";
import type { TelegramHandlerAuthorization } from "./bot-handlers.inbound-authorization.js";
import type { TelegramMessagePipeline } from "./bot-handlers.message-pipeline.js";
import type { RegisterTelegramHandlerParams, TelegramEventBindings } from "./bot-handlers.types.js";
import {
isTelegramSpooledReplayUpdate,
recordTelegramMessageProcessingResult,
} from "./bot-processing-outcome.js";
import {
buildTelegramGroupPeerId,
buildTelegramParentPeer,
resolveTelegramThreadSpec,
type TelegramThreadSpec,
} from "./bot/helpers.js";
import { resolveTelegramConversationRoute } from "./conversation-route.js";
import { migrateTelegramGroupConfig } from "./group-migration.js";
import { getPreparedTelegramPollAnswer } from "./poll-answer-context.js";
import { findTelegramPollRegistryEntry, retireTelegramPollRegistryEntry } from "./poll-registry.js";
/** Stable operator-facing reason for a scoped reaction dropped without a known topic. */
const TELEGRAM_REACTION_THREAD_UNRESOLVED_REASON = "thread-context-unavailable";
type TelegramEventMessageDependencies = Pick<
TelegramMessagePipeline,
| "resolveCachedMessageThreadSpec"
| "buildSyntheticTextMessage"
| "buildSyntheticContext"
| "processMessageWithReplyChain"
>;
type CreateTelegramEventBindingsOptions = {
params: RegisterTelegramHandlerParams;
message: TelegramEventMessageDependencies;
authorization: Pick<
TelegramHandlerAuthorization,
"resolveTelegramEventAuthorizationContext" | "authorizeTelegramEventSender"
>;
registerMessages: () => void;
};
function isCurrentTelegramChatMember(member: ChatMember): boolean {
return (
member.status === "creator" ||
member.status === "administrator" ||
member.status === "member" ||
(member.status === "restricted" && member.is_member)
);
}
export function createTelegramEventBindings({
params,
message,
authorization,
registerMessages,
}: CreateTelegramEventBindingsOptions): TelegramEventBindings {
const { accountId, bot, cfg, runtime, shouldSkipUpdate, telegramDeps } = params;
const { authorizeTelegramEventSender, resolveTelegramEventAuthorizationContext } = authorization;
const {
buildSyntheticContext,
buildSyntheticTextMessage,
processMessageWithReplyChain,
resolveCachedMessageThreadSpec,
} = message;
const registerReaction = () => {
bot.on("message_reaction", async (ctx) => {
try {
const reaction = ctx.messageReaction;
if (!reaction || shouldSkipUpdate(ctx)) {
return;
}
const chatId = reaction.chat.id;
const messageId = reaction.message_id;
const user = reaction.user;
const senderId = user?.id != null ? String(user.id) : "";
const senderUsername = user?.username ?? "";
const isGroup = reaction.chat.type === "group" || reaction.chat.type === "supergroup";
const isDirectMessagesChat = reaction.chat.is_direct_messages === true;
const isForum = !isDirectMessagesChat && reaction.chat.is_forum === true;
const authorizationCfg = telegramDeps.getRuntimeConfig();
const authorizationTelegramCfg = resolveTelegramAccount({
cfg: authorizationCfg,
accountId,
}).config;
const reactionMode = authorizationTelegramCfg.reactionNotifications ?? "own";
if (reactionMode === "off" || user?.is_bot) {
return;
}
if (
reactionMode === "own" &&
!telegramDeps.wasSentByBot(chatId, messageId, authorizationCfg)
) {
logVerbose(
`telegram: skipped reaction on msg ${messageId} in chat ${chatId} (own mode, not sent by bot)`,
);
return;
}
// Detect additions before topic recovery so no-op reactions avoid cache work and warnings.
const oldEmojis = new Set(
reaction.old_reaction
.filter((item): item is ReactionTypeEmoji => item.type === "emoji")
.map((item) => item.emoji),
);
const addedReactions = reaction.new_reaction
.filter((item): item is ReactionTypeEmoji => item.type === "emoji")
.filter((item) => !oldEmojis.has(item.emoji));
if (addedReactions.length === 0) {
return;
}
// Reaction updates omit every topic field. Scoped reactions require the bounded cache.
let recoveredThreadSpec: TelegramThreadSpec | undefined;
const requiredScope = isDirectMessagesChat
? "direct-messages"
: isForum
? "forum"
: undefined;
if (requiredScope) {
recoveredThreadSpec = await resolveCachedMessageThreadSpec({ chatId, messageId });
if (
recoveredThreadSpec?.scope !== requiredScope ||
recoveredThreadSpec.id === undefined
) {
runtime.log?.(
warn(
`telegram: skipped scoped reaction account=${accountId} chat=${chatId} message=${messageId} reason=${TELEGRAM_REACTION_THREAD_UNRESOLVED_REASON}`,
),
);
return;
}
}
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
cfg: authorizationCfg,
chatId,
isGroup,
senderId,
threadSpec:
recoveredThreadSpec ??
resolveTelegramThreadSpec({
isGroup,
isForum,
}),
});
const senderAuthorization = await authorizeTelegramEventSender({
chatId,
chatTitle: reaction.chat.title,
isGroup,
senderId,
senderUsername,
mode: "reaction",
context: eventAuthContext,
});
if (!senderAuthorization) {
return;
}
// DM reactions cannot prove topic membership because Telegram omits the thread id.
if (!isGroup) {
const requireTopic =
eventAuthContext.groupConfig && "requireTopic" in eventAuthContext.groupConfig
? eventAuthContext.groupConfig.requireTopic
: undefined;
if (requireTopic === true) {
logVerbose(
`Blocked telegram reaction in DM ${chatId}: requireTopic=true but topic unknown for reactions`,
);
return;
}
}
const resolvedThreadId = eventAuthContext.resolvedThreadId;
let sessionKey: string;
if (recoveredThreadSpec) {
// Scoped topics must retain topic agents and conversation bindings.
sessionKey = resolveTelegramConversationRoute({
cfg: eventAuthContext.cfg,
accountId,
chatId,
isGroup,
resolvedThreadId,
replyThreadId: recoveredThreadSpec.id,
senderId,
topicAgentId: eventAuthContext.topicConfig?.agentId,
}).route.sessionKey;
} else {
// Direct chats and non-forum groups retain their established peer route.
const peerId = isGroup
? buildTelegramGroupPeerId(chatId, resolvedThreadId)
: String(chatId);
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
sessionKey = resolveAgentRoute({
cfg: eventAuthContext.cfg,
channel: "telegram",
accountId,
peer: { kind: isGroup ? "group" : "direct", id: peerId },
parentPeer,
}).sessionKey;
}
const senderName = user
? [user.first_name, user.last_name].filter(Boolean).join(" ").trim() || user.username
: undefined;
const senderUsernameLabel = user?.username ? `@${user.username}` : undefined;
let senderLabel = senderName;
if (senderName && senderUsernameLabel) {
senderLabel = `${senderName} (${senderUsernameLabel})`;
} else if (!senderName && senderUsernameLabel) {
senderLabel = senderUsernameLabel;
}
if (!senderLabel && user?.id) {
senderLabel = `id:${user.id}`;
}
senderLabel = senderLabel || "unknown";
for (const addedReaction of addedReactions) {
const emoji = addedReaction.emoji;
const text = `Telegram reaction added: ${emoji} by ${senderLabel} on msg ${messageId}`;
telegramDeps.enqueueSystemEvent(text, {
sessionKey,
contextKey: `telegram:reaction:add:${chatId}:${messageId}:${user?.id ?? "anon"}:${emoji}`,
});
logVerbose(`telegram: reaction event enqueued: ${text}`);
}
} catch (err) {
runtime.error?.(danger(`telegram reaction handler failed: ${String(err)}`));
throw err;
}
});
};
const registerPolls = () => {
bot.on("poll", async (ctx) => {
try {
const poll = ctx.poll;
if (!poll?.is_closed || shouldSkipUpdate(ctx)) {
return;
}
await retireTelegramPollRegistryEntry({ accountId, pollId: poll.id });
} catch (err) {
runtime.error?.(danger(`telegram poll handler failed: ${String(err)}`));
if (isTelegramSpooledReplayUpdate(ctx.update)) {
recordTelegramMessageProcessingResult({ kind: "failed-retryable", error: err });
return;
}
throw err;
}
});
// Public poll answers omit chat/thread data. The send path persists that origin.
bot.on("poll_answer", async (ctx) => {
try {
const pollAnswer = ctx.pollAnswer;
if (!pollAnswer || shouldSkipUpdate(ctx)) {
return;
}
const optionIds = pollAnswer.option_ids ?? [];
const user = pollAnswer.user;
// Retractions and voters without a usable user identity cannot pass authorization.
if (optionIds.length === 0 || !user || user.is_bot) {
return;
}
// Store failures replay durable ingress; only a true miss is a safe no-op.
const pollId = pollAnswer.poll_id;
const prepared = getPreparedTelegramPollAnswer(ctx.update);
const entry = prepared
? prepared.entry
: await findTelegramPollRegistryEntry({ pollId, accountId });
if (!entry) {
logVerbose(`telegram: poll_answer for poll ${pollId} has no registry entry; skipping`);
return;
}
const chatId = entry.chat.id;
const isGroup = entry.chat.type === "group" || entry.chat.type === "supergroup";
const senderId = String(user.id);
const senderUsername = user.username ?? "";
if (!isGroup && user.id !== chatId) {
logVerbose(`Blocked forwarded telegram poll_answer for DM ${chatId} from ${senderId}`);
return;
}
if (isGroup && !isCurrentTelegramChatMember(await bot.api.getChatMember(chatId, user.id))) {
logVerbose(
`Blocked forwarded telegram poll_answer for group ${chatId} from non-member ${senderId}`,
);
return;
}
const authorizationCfg = telegramDeps.getRuntimeConfig();
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
cfg: authorizationCfg,
chatId,
isGroup,
senderId,
threadSpec: entry.threadSpec,
});
const senderAuthorization = await authorizeTelegramEventSender({
chatId,
chatTitle: "title" in entry.chat ? entry.chat.title : undefined,
isGroup,
senderId,
senderUsername,
mode: "reaction",
context: eventAuthContext,
});
if (!senderAuthorization) {
return;
}
// A DM poll without persisted topic context must not wake the base DM session.
const requireTopic =
eventAuthContext.groupConfig && "requireTopic" in eventAuthContext.groupConfig
? eventAuthContext.groupConfig.requireTopic
: undefined;
if (!isGroup && requireTopic === true) {
if (eventAuthContext.dmThreadId == null) {
logVerbose(
`Blocked telegram poll_answer in DM ${chatId}: requireTopic=true but topic unknown`,
);
return;
}
}
const optionLabels = optionIds.map((index) => entry.options[index] ?? `option ${index}`);
const text = `Poll response to "${entry.question}": ${optionLabels.join(", ")}`;
const messageThreadId = "id" in entry.threadSpec ? entry.threadSpec.id : undefined;
const syntheticMessage = buildSyntheticTextMessage({
base: {
message_id: entry.messageId,
date: Math.floor(Date.now() / 1000),
chat: entry.chat,
...(messageThreadId == null
? {}
: {
message_thread_id: messageThreadId,
is_topic_message: true,
}),
},
from: user,
text,
});
const result = await processMessageWithReplyChain({
ctx: buildSyntheticContext(ctx, syntheticMessage),
msg: syntheticMessage,
allMedia: [],
storeAllowFrom: eventAuthContext.storeAllowFrom,
options: {
forceWasMentioned: true,
messageIdOverride:
typeof ctx.update.update_id === "number"
? String(ctx.update.update_id)
: `poll:${pollId}:${user.id}:${optionIds.join("-")}`,
},
});
recordTelegramMessageProcessingResult(result);
logVerbose(`telegram: poll_answer dispatched for poll ${pollId} by ${senderId}`);
} catch (err) {
runtime.error?.(danger(`telegram poll_answer handler failed: ${String(err)}`));
if (isTelegramSpooledReplayUpdate(ctx.update)) {
recordTelegramMessageProcessingResult({ kind: "failed-retryable", error: err });
return;
}
throw err;
}
});
};
const registerMigration = () => {
bot.on("message:migrate_to_chat_id", async (ctx) => {
try {
const msg = ctx.message;
if (!msg?.migrate_to_chat_id || shouldSkipUpdate(ctx)) {
return;
}
const oldChatId = String(msg.chat.id);
const newChatId = String(msg.migrate_to_chat_id);
const chatTitle = msg.chat.title ?? "Unknown";
runtime.log?.(
warn(`[telegram] Group migrated: "${chatTitle}" ${oldChatId}${newChatId}`),
);
if (!resolveChannelConfigWrites({ cfg, channelId: "telegram", accountId })) {
runtime.log?.(
warn("[telegram] Config writes disabled; skipping group config migration."),
);
return;
}
const currentConfig = telegramDeps.getRuntimeConfig();
const migration = migrateTelegramGroupConfig({
cfg: currentConfig,
accountId,
oldChatId,
newChatId,
});
if (migration.migrated) {
runtime.log?.(
warn(`[telegram] Migrating group config from ${oldChatId} to ${newChatId}`),
);
migrateTelegramGroupConfig({ cfg, accountId, oldChatId, newChatId });
await mutateConfigFile({
afterWrite: { mode: "auto" },
mutate: (draft) => {
migrateTelegramGroupConfig({ cfg: draft, accountId, oldChatId, newChatId });
},
});
runtime.log?.(warn("[telegram] Group config migrated and saved successfully"));
} else if (migration.skippedExisting) {
runtime.log?.(
warn(
`[telegram] Group config already exists for ${newChatId}; leaving ${oldChatId} unchanged`,
),
);
} else {
runtime.log?.(
warn(`[telegram] No config found for old group ID ${oldChatId}, migration logged only`),
);
}
} catch (err) {
runtime.error?.(danger(`[telegram] Group migration handler failed: ${String(err)}`));
throw err;
}
});
};
return { registerReaction, registerPolls, registerMigration, registerMessages };
}
@@ -1,9 +1,11 @@
// Telegram sender authorization shared by message, reaction, and callback handlers.
import type { Message } from "grammy/types";
import type {
DmPolicy,
OpenClawConfig,
TelegramAccountConfig,
TelegramDirectConfig,
TelegramGroupConfig,
TelegramTopicConfig,
} from "openclaw/plugin-sdk/config-contracts";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { expandTelegramAllowFromWithAccessGroups } from "./access-groups.js";
@@ -13,9 +15,8 @@ import {
resolveTelegramEffectiveDmPolicy,
type NormalizedAllowFrom,
} from "./bot-access.js";
import { shouldSkipTelegramGroupMessage } from "./bot-handlers.authorization-groups.runtime.js";
import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js";
import { resolveTelegramMessageTurnSettings } from "./bot-message.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import {
isTelegramCommandsAllowFromConfigured,
resolveTelegramCommandAuthorization,
@@ -24,6 +25,10 @@ import {
type TelegramThreadSpec,
} from "./bot/helpers.js";
import { enforceTelegramDmAccess, isTelegramDmAccessAllowed } from "./dm-access.js";
import {
evaluateTelegramGroupBaseAccess,
evaluateTelegramGroupPolicyAccess,
} from "./group-access.js";
import {
resolveTelegramCommandIngressAuthorization,
resolveTelegramEventIngressAuthorization,
@@ -35,7 +40,43 @@ export type TelegramEventAuthorizationMode =
| "callback-allowlist"
| "callback-runtime-allowlist";
export function createTelegramHandlerAuthorizationRuntime({
export interface TelegramHandlerAuthorization {
resolveTelegramEventAuthorizationContext: (params: {
cfg: OpenClawConfig;
chatId: number;
isGroup: boolean;
senderId?: string;
threadSpec: TelegramThreadSpec;
}) => Promise<TelegramEventAuthorizationContext>;
authorizeTelegramEventSender: (params: {
chatId: number;
chatTitle?: string;
isGroup: boolean;
senderId: string;
senderUsername: string;
mode: TelegramEventAuthorizationMode;
context: TelegramEventAuthorizationContext;
}) => Promise<boolean>;
isTelegramModelCallbackAuthorized: (params: {
chatId: number;
isGroup: boolean;
senderId: string;
senderUsername: string;
context: TelegramEventAuthorizationContext;
}) => Promise<boolean>;
authorizeInboundMessage: (params: {
msg: Message;
chatId: number;
isGroup: boolean;
isForum: boolean;
senderId: string;
senderUsername: string;
requireConfiguredGroup: boolean;
dmAccess: "challenge" | "silent";
}) => Promise<TelegramInboundGate>;
}
export function createTelegramHandlerAuthorization({
accountId,
bot,
opts,
@@ -43,17 +84,11 @@ export function createTelegramHandlerAuthorizationRuntime({
telegramDeps,
resolveGroupPolicy,
resolveTelegramGroupConfig,
}: RegisterTelegramHandlerParams) {
}: RegisterTelegramHandlerParams): TelegramHandlerAuthorization {
const shouldSkipGroupMessage = (params: Parameters<typeof shouldSkipTelegramGroupMessage>[0]) =>
shouldSkipTelegramGroupMessage(params, { logger, resolveGroupPolicy });
type TelegramGroupAllowContext = Awaited<ReturnType<typeof resolveTelegramGroupAllowFromContext>>;
type TelegramEventAuthorizationContextValue = TelegramGroupAllowContext & {
cfg: OpenClawConfig;
telegramCfg: TelegramAccountConfig;
allowFrom: ReturnType<typeof resolveTelegramMessageTurnSettings>["allowFrom"];
dmPolicy: DmPolicy;
};
type TelegramEventAuthorizationContextValue = TelegramEventAuthorizationContext;
const TELEGRAM_EVENT_AUTH_RULES: Record<
TelegramEventAuthorizationMode,
{
@@ -294,14 +329,6 @@ export function createTelegramHandlerAuthorizationRuntime({
})
).authorized;
};
type TelegramInboundGate =
| { allowed: false }
| {
allowed: true;
context: TelegramEventAuthorizationContextValue;
effectiveDmAllow: NormalizedAllowFrom;
};
// Single authorization gate for every message-like update that can reach the
// reply-chain cache or dispatch: fresh messages, edits, channel posts. Must run
// before any cache/dedupe side effect so blocked content is never recorded.
@@ -418,6 +445,130 @@ export function createTelegramHandlerAuthorizationRuntime({
};
}
export type TelegramHandlerAuthorizationRuntime = ReturnType<
typeof createTelegramHandlerAuthorizationRuntime
>;
type TelegramEventAuthorizationContext = {
cfg: OpenClawConfig;
telegramCfg: TelegramAccountConfig;
allowFrom?: Array<string | number>;
dmPolicy: DmPolicy;
threadSpec: TelegramThreadSpec;
resolvedThreadId?: number;
dmThreadId?: number;
storeAllowFrom: string[];
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
topicConfig?: TelegramTopicConfig;
groupAllowOverride?: Array<string | number>;
effectiveGroupAllow: NormalizedAllowFrom;
hasGroupAllowOverride: boolean;
};
type TelegramInboundGate =
| { allowed: false }
| {
allowed: true;
context: TelegramEventAuthorizationContext;
effectiveDmAllow: NormalizedAllowFrom;
};
function shouldSkipTelegramGroupMessage(
params: {
isGroup: boolean;
chatId: string | number;
chatTitle?: string;
resolvedThreadId?: number;
senderId: string;
senderUsername: string;
effectiveGroupAllow: NormalizedAllowFrom;
hasGroupAllowOverride: boolean;
groupConfig?: TelegramGroupConfig;
topicConfig?: TelegramTopicConfig;
cfg: OpenClawConfig;
telegramCfg: TelegramAccountConfig;
},
runtime: Pick<RegisterTelegramHandlerParams, "logger" | "resolveGroupPolicy">,
): boolean {
const {
isGroup,
chatId,
chatTitle,
resolvedThreadId,
senderId,
senderUsername,
effectiveGroupAllow,
hasGroupAllowOverride,
groupConfig,
topicConfig,
cfg,
telegramCfg,
} = params;
const baseAccess = evaluateTelegramGroupBaseAccess({
isGroup,
groupConfig,
topicConfig,
hasGroupAllowOverride,
effectiveGroupAllow,
senderId,
senderUsername,
enforceAllowOverride: true,
requireSenderForAllowOverride: true,
});
if (!baseAccess.allowed) {
if (baseAccess.reason === "group-disabled") {
logVerbose(`Blocked telegram group ${chatId} (group disabled)`);
return true;
}
if (baseAccess.reason === "topic-disabled") {
logVerbose(
`Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
);
return true;
}
logVerbose(`Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`);
return true;
}
if (!isGroup) {
return false;
}
const policyAccess = evaluateTelegramGroupPolicyAccess({
isGroup,
chatId,
cfg,
telegramCfg,
topicConfig,
groupConfig,
effectiveGroupAllow,
senderId,
senderUsername,
resolveGroupPolicy: runtime.resolveGroupPolicy,
enforcePolicy: true,
enforceAllowlistAuthorization: true,
allowEmptyAllowlistEntries: false,
requireSenderForAllowlistAuthorization: true,
checkChatAllowlist: true,
});
if (policyAccess.allowed) {
return false;
}
if (policyAccess.reason === "group-policy-disabled") {
logVerbose("Blocked telegram group message (groupPolicy: disabled)");
return true;
}
if (policyAccess.reason === "group-policy-allowlist-no-sender") {
logVerbose("Blocked telegram group message (no sender ID, groupPolicy: allowlist)");
return true;
}
if (policyAccess.reason === "group-policy-allowlist-empty") {
logVerbose(
"Blocked telegram group message (groupPolicy: allowlist, no group allowlist entries)",
);
return true;
}
if (policyAccess.reason === "group-policy-allowlist-unauthorized") {
logVerbose(`Blocked telegram group message from ${senderId} (groupPolicy: allowlist)`);
return true;
}
runtime.logger.info(
{ chatId, title: chatTitle, reason: "not-allowed" },
"skipping group message",
);
return true;
}
@@ -1,4 +1,3 @@
// Telegram inbound debounce lanes and batch flushing.
import type { Message } from "grammy/types";
import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-inbound";
import {
@@ -6,11 +5,12 @@ import {
resolveInboundDebounceMs,
} from "openclaw/plugin-sdk/channel-inbound-debounce";
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js";
import type { TelegramMessagePipeline } from "./bot-handlers.message-pipeline.js";
import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js";
import type { TelegramMediaRef } from "./bot-message-context.js";
import type { TelegramAmbientTranscriptWatermark } from "./bot-message-context.types.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import type { TelegramSpooledReplayDeferredParticipant } from "./bot-processing-outcome.js";
import {
buildTelegramThreadParams,
@@ -39,10 +39,51 @@ export type TelegramDebounceEntry = {
spooledReplayParticipant?: TelegramSpooledReplayDeferredParticipant;
};
export function createTelegramInboundDebounceRuntime(
{ cfg, bot, runtime }: Pick<RegisterTelegramHandlerParams, "cfg" | "bot" | "runtime">,
messageRuntime: TelegramHandlerMessageRuntime,
) {
type TextFragmentEntry = {
key: string;
storeAllowFrom: string[];
messages: Array<{ msg: Message; ctx: TelegramContext; receivedAtMs: number }>;
promptContextMinTimestampMs?: number;
promptContextAmbientWatermark?: TelegramAmbientTranscriptWatermark;
dispatchDedupeClaims: TelegramMessageDispatchReplayClaim[];
spooledReplayParticipants: TelegramSpooledReplayDeferredParticipant[];
timer: ReturnType<typeof setTimeout>;
};
type TelegramTextFragmentInput = {
ctx: TelegramContext;
msg: Message;
chatId: number;
resolvedThreadId?: number;
dmThreadId?: number;
storeAllowFrom: string[];
isAbortControlMessage: boolean;
isAuthorizedAbortControlMessage: () => Promise<boolean>;
promptContextMinTimestampMs?: number;
promptContextAmbientWatermark?: TelegramAmbientTranscriptWatermark;
dispatchDedupeClaims: TelegramMessageDispatchReplayClaim[];
};
interface TelegramInboundBuffers {
inboundDebouncer: {
enqueue: (entry: TelegramDebounceEntry) => Promise<void>;
flushKey: (key: string) => Promise<void>;
cancelKey: (key: string) => boolean;
drain: () => Promise<void>;
};
resolveTelegramDebounceEntryMs: (entry: TelegramDebounceEntry) => number;
shouldDebounceTelegramEntry: (entry: TelegramDebounceEntry) => boolean;
resolveTelegramDebounceLane: (msg: Message) => TelegramDebounceLane;
handleTextFragment: (params: TelegramTextFragmentInput) => Promise<boolean>;
}
export function createTelegramInboundBuffers({
params: { cfg, bot, runtime, opts },
message,
}: {
params: Pick<RegisterTelegramHandlerParams, "cfg" | "bot" | "runtime" | "opts">;
message: TelegramMessagePipeline;
}): TelegramInboundBuffers {
const {
promptContextBoundaryOptions,
latestPromptContextMinTimestampMs,
@@ -51,12 +92,13 @@ export function createTelegramInboundDebounceRuntime(
releaseDispatchDedupeClaims,
buildFailedProcessingResult,
settleSpooledReplayParticipants,
createSpooledReplayParticipantForBufferedWork,
spooledReplayOptions,
buildSyntheticTextMessage,
buildSyntheticContext,
formatTelegramAmbientTranscriptBody,
processMessageWithReplyChain,
} = messageRuntime;
} = message;
const debounceMs = resolveInboundDebounceMs({ cfg, channel: "telegram" });
const FORWARD_BURST_DEBOUNCE_MS = 80;
const resolveTelegramDebounceEntryMs = (entry: TelegramDebounceEntry): number =>
@@ -141,7 +183,6 @@ export function createTelegramInboundDebounceRuntime(
settleSpooledReplayParticipants(participants, { kind: "skipped" });
return;
}
// Single entries return above with their original message and structured forward metadata.
const first = expectDefined(entries.at(0), "multi-entry Telegram debounce batch");
const syntheticMessage = {
...buildSyntheticTextMessage({
@@ -235,10 +276,171 @@ export function createTelegramInboundDebounceRuntime(
},
});
const maxGapMs =
typeof opts.testTimings?.textFragmentGapMs === "number" &&
Number.isFinite(opts.testTimings.textFragmentGapMs)
? Math.max(10, Math.floor(opts.testTimings.textFragmentGapMs))
: 1500;
const textBuffer = new Map<string, TextFragmentEntry>();
const textQueue = new KeyedAsyncQueue();
const flushTextFragments = async (entry: TextFragmentEntry) => {
try {
entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id);
const first = entry.messages[0];
const last = entry.messages.at(-1);
if (!first || !last) {
releaseDispatchDedupeClaims(entry.dispatchDedupeClaims);
settleSpooledReplayParticipants(entry.spooledReplayParticipants, { kind: "skipped" });
return;
}
const combinedTextParts = joinTelegramTextParts(
entry.messages.map((bufferedMessage) => bufferedMessage.msg),
"",
);
const combinedText = combinedTextParts.text;
if (!combinedText.trim()) {
releaseDispatchDedupeClaims(entry.dispatchDedupeClaims);
settleSpooledReplayParticipants(entry.spooledReplayParticipants, { kind: "skipped" });
return;
}
const syntheticMessage = buildSyntheticTextMessage({
base: first.msg,
text: combinedText,
entities: combinedTextParts.entities,
date: last.msg.date ?? first.msg.date,
});
const result = await processMessageWithReplyChain({
ctx: buildSyntheticContext(first.ctx, syntheticMessage),
msg: syntheticMessage,
allMedia: [],
storeAllowFrom: entry.storeAllowFrom,
options: {
messageIdOverride: String(last.msg.message_id),
ambientTranscriptBody: formatTelegramAmbientTranscriptBody(
entry.messages.map((bufferedMessage) => bufferedMessage.msg),
),
receivedAtMs: first.receivedAtMs,
ingressBuffer: "text-fragment",
...promptContextBoundaryOptions(
entry.promptContextMinTimestampMs,
entry.promptContextAmbientWatermark,
),
...spooledReplayOptions(entry.spooledReplayParticipants),
},
dispatchDedupeClaims: entry.dispatchDedupeClaims,
spooledReplayParticipants: entry.spooledReplayParticipants,
});
settleSpooledReplayParticipants(entry.spooledReplayParticipants, result);
} catch (error) {
releaseDispatchDedupeClaims(entry.dispatchDedupeClaims, error);
settleSpooledReplayParticipants(
entry.spooledReplayParticipants,
buildFailedProcessingResult(error),
);
runtime.error?.(danger(`text fragment handler failed: ${String(error)}`));
}
};
const queueTextFlush = async (entry: TextFragmentEntry) => {
await textQueue.enqueue(entry.key, async () => {
await flushTextFragments(entry).catch(() => undefined);
});
};
const runTextFlush = async (entry: TextFragmentEntry) => {
textBuffer.delete(entry.key);
await queueTextFlush(entry);
};
const scheduleTextFlush = (entry: TextFragmentEntry) => {
clearTimeout(entry.timer);
entry.timer = setTimeout(() => void runTextFlush(entry), maxGapMs);
};
const handleTextFragment = async (params: TelegramTextFragmentInput): Promise<boolean> => {
const text = typeof params.msg.text === "string" ? params.msg.text : undefined;
const isCommandLike = (text ?? "").trim().startsWith("/");
const senderId = params.msg.from?.id != null ? String(params.msg.from.id) : "unknown";
const threadId = params.resolvedThreadId ?? params.dmThreadId;
const key = `text:${params.chatId}:${threadId ?? "main"}:${senderId}`;
if (text && !isCommandLike && !params.isAbortControlMessage) {
const nowMs = Date.now();
const existing = textBuffer.get(key);
if (existing) {
const last = existing.messages.at(-1);
const idGap = last ? params.msg.message_id - last.msg.message_id : Infinity;
const timeGapMs = nowMs - (last?.receivedAtMs ?? nowMs);
const canAppend = idGap > 0 && idGap <= 1 && timeGapMs >= 0 && timeGapMs <= maxGapMs;
const nextTotalChars =
existing.messages.reduce(
(sum, bufferedMessage) => sum + (bufferedMessage.msg.text?.length ?? 0),
0,
) + text.length;
if (canAppend && existing.messages.length < 12 && nextTotalChars <= 50_000) {
const participant = createSpooledReplayParticipantForBufferedWork(
`text-fragment:${key}:${params.msg.message_id}`,
);
if (participant) {
existing.spooledReplayParticipants.push(participant);
}
existing.messages.push({ msg: params.msg, ctx: params.ctx, receivedAtMs: nowMs });
existing.promptContextMinTimestampMs = latestPromptContextMinTimestampMs(
existing.promptContextMinTimestampMs,
params.promptContextMinTimestampMs,
);
existing.promptContextAmbientWatermark = latestPromptContextAmbientWatermark(
existing.promptContextAmbientWatermark,
params.promptContextAmbientWatermark,
);
existing.dispatchDedupeClaims = mergeDispatchDedupeClaims(
existing.dispatchDedupeClaims,
params.dispatchDedupeClaims,
);
scheduleTextFlush(existing);
return true;
}
clearTimeout(existing.timer);
textBuffer.delete(key);
await queueTextFlush(existing);
}
if (text.length >= 4000) {
const participant = createSpooledReplayParticipantForBufferedWork(
`text-fragment:${key}:${params.msg.message_id}`,
);
const entry: TextFragmentEntry = {
key,
storeAllowFrom: params.storeAllowFrom,
messages: [{ msg: params.msg, ctx: params.ctx, receivedAtMs: nowMs }],
dispatchDedupeClaims: params.dispatchDedupeClaims,
spooledReplayParticipants: participant ? [participant] : [],
...promptContextBoundaryOptions(
params.promptContextMinTimestampMs,
params.promptContextAmbientWatermark,
),
timer: setTimeout(() => {}, maxGapMs),
};
textBuffer.set(key, entry);
scheduleTextFlush(entry);
return true;
}
} else if (
text &&
params.isAbortControlMessage &&
(await params.isAuthorizedAbortControlMessage())
) {
const existing = textBuffer.get(key);
if (existing) {
clearTimeout(existing.timer);
textBuffer.delete(key);
releaseDispatchDedupeClaims(existing.dispatchDedupeClaims);
settleSpooledReplayParticipants(existing.spooledReplayParticipants, { kind: "skipped" });
}
}
return false;
};
return {
inboundDebouncer,
resolveTelegramDebounceEntryMs,
shouldDebounceTelegramEntry,
resolveTelegramDebounceLane,
handleTextFragment,
};
}
@@ -1,4 +1,3 @@
// Telegram media-group buffering and mention-aware album dispatch.
import type { Message } from "grammy/types";
import {
buildMentionRegexes,
@@ -21,10 +20,10 @@ import {
isDurablyRetryableInboundMediaError,
isRecoverableMediaGroupError,
} from "./bot-handlers.media.js";
import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js";
import type { TelegramMessagePipeline } from "./bot-handlers.message-pipeline.js";
import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js";
import type { TelegramMediaRef } from "./bot-message-context.js";
import type { TelegramAmbientTranscriptWatermark } from "./bot-message-context.types.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import type { TelegramSpooledReplayDeferredParticipant } from "./bot-processing-outcome.js";
import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js";
import { resolveMedia } from "./bot/delivery.resolve-media.js";
@@ -72,7 +71,17 @@ type BufferedMediaGroupEntry = MediaGroupEntry &
type TelegramGroupMediaDisposition = "process" | "skip" | "silent-ingest";
export function createTelegramInboundMediaGroupRuntime(
interface TelegramInboundMedia {
handleMediaGroup: (input: TelegramMediaGroupInput) => boolean;
resolveUnaddressedGroupMediaDisposition: (
authorization: MediaAuthorization & { ctx: TelegramContext; msg: Message },
) => Promise<TelegramGroupMediaDisposition>;
}
export function createTelegramInboundMedia({
params,
message,
}: {
params: Pick<
RegisterTelegramHandlerParams,
| "accountId"
@@ -83,9 +92,9 @@ export function createTelegramInboundMediaGroupRuntime(
| "logger"
| "resolveGroupActivation"
| "resolveGroupRequireMention"
>,
messageRuntime: TelegramHandlerMessageRuntime,
) {
>;
message: TelegramMessagePipeline;
}): TelegramInboundMedia {
const {
accountId,
bot,
@@ -110,7 +119,7 @@ export function createTelegramInboundMediaGroupRuntime(
spooledReplayOptions,
resolveTelegramSessionState,
processMessageWithReplyChain,
} = messageRuntime;
} = message;
const timeoutMs =
typeof opts.testTimings?.mediaGroupFlushMs === "number" &&
Number.isFinite(opts.testTimings.mediaGroupFlushMs)
@@ -0,0 +1,57 @@
// Telegram tests cover inbound buffering identity.
import { describe, expect, it } from "vitest";
import {
buildTelegramInboundDebounceConversationKey,
buildTelegramInboundDebounceKey,
} from "./bot-handlers.debounce-key.js";
describe("buildTelegramInboundDebounceKey", () => {
it("uses the resolved account id instead of literal default when provided", () => {
expect(
buildTelegramInboundDebounceKey({
accountId: "work",
conversationKey: "12345",
senderId: "67890",
debounceLane: "default",
}),
).toBe("telegram:work:12345:67890:default");
});
it("falls back to literal default only when account id is actually absent", () => {
expect(
buildTelegramInboundDebounceKey({
accountId: undefined,
conversationKey: "12345",
senderId: "67890",
debounceLane: "forward",
}),
).toBe("telegram:default:12345:67890:forward");
});
it("keeps direct topic thread ids in the conversation key", () => {
const topic100 = buildTelegramInboundDebounceConversationKey({ chatId: 7, threadId: 100 });
const topic200 = buildTelegramInboundDebounceConversationKey({ chatId: 7, threadId: 200 });
expect(topic100).toBe("7:topic:100");
expect(topic200).toBe("7:topic:200");
expect(
buildTelegramInboundDebounceKey({
accountId: "default",
conversationKey: topic100,
senderId: "42",
debounceLane: "default",
}),
).not.toBe(
buildTelegramInboundDebounceKey({
accountId: "default",
conversationKey: topic200,
senderId: "42",
debounceLane: "default",
}),
);
});
it("uses the chat id as the conversation key when no thread is present", () => {
expect(buildTelegramInboundDebounceConversationKey({ chatId: 7 })).toBe("7");
});
});
@@ -1,12 +1,17 @@
// Telegram message-like update registration and cache/dispatch ordering.
import type { Context } from "grammy";
import type { Message } from "grammy/types";
import type { TelegramGroupConfig } from "openclaw/plugin-sdk/config-contracts";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import type { TelegramHandlerAuthorizationRuntime } from "./bot-handlers.authorization.runtime.js";
import type { TelegramHandlerInboundRuntime } from "./bot-handlers.inbound.runtime.js";
import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import type { TelegramHandlerAuthorization } from "./bot-handlers.inbound-authorization.js";
import { createTelegramInboundProcessing } from "./bot-handlers.inbound-processing.js";
import type { TelegramInboundProcessing } from "./bot-handlers.inbound-processing.js";
import type { TelegramMessagePipeline } from "./bot-handlers.message-pipeline.js";
import type {
RegisterTelegramHandlerParams,
TelegramInboundDisposition,
TelegramInboundPipeline,
} from "./bot-handlers.types.js";
import {
isTelegramSpooledReplayUpdate,
recordTelegramMessageProcessingResult,
@@ -30,7 +35,7 @@ type TelegramMessageHandlerParams = Pick<
};
type TelegramMessageHandlerRuntime = Pick<
TelegramHandlerMessageRuntime,
TelegramMessagePipeline,
| "normalizePromptContextMinTimestampMs"
| "promptContextBoundaryOptions"
| "releaseDispatchDedupeClaims"
@@ -40,16 +45,23 @@ type TelegramMessageHandlerRuntime = Pick<
| "resolvePromptContextAmbientWatermark"
> & {
recordMessageForReplyChain: (
...args: Parameters<TelegramHandlerMessageRuntime["recordMessageForReplyChain"]>
...args: Parameters<TelegramMessagePipeline["recordMessageForReplyChain"]>
) => Promise<unknown>;
};
export function registerTelegramMessageHandlers(
interface TelegramInboundHandlers {
handleMessage: (ctx: Context) => Promise<TelegramInboundDisposition>;
handleEditedMessage: (ctx: Context) => Promise<TelegramInboundDisposition>;
handleChannelPost: (ctx: Context) => Promise<TelegramInboundDisposition>;
handleEditedChannelPost: (ctx: Context) => Promise<TelegramInboundDisposition>;
}
function createTelegramInboundHandlers(
{ bot, opts, runtime, shouldSkipUpdate }: TelegramMessageHandlerParams,
messageRuntime: TelegramMessageHandlerRuntime,
authorizationRuntime: Pick<TelegramHandlerAuthorizationRuntime, "authorizeInboundMessage">,
inboundRuntime: Pick<TelegramHandlerInboundRuntime, "processInboundMessage">,
) {
authorizationRuntime: Pick<TelegramHandlerAuthorization, "authorizeInboundMessage">,
inboundRuntime: Pick<TelegramInboundProcessing, "processInboundMessage">,
): TelegramInboundHandlers {
const {
normalizePromptContextMinTimestampMs,
promptContextBoundaryOptions,
@@ -147,11 +159,13 @@ export function registerTelegramMessageHandlers(
await recordMessageForReplyChain(normalizedMsg, gate.context.threadSpec, params.botUserId);
};
const handleInboundMessageLike = async (event: InboundTelegramEvent) => {
const handleInboundMessageLike = async (
event: InboundTelegramEvent,
): Promise<TelegramInboundDisposition> => {
let dispatchDedupeClaims: TelegramMessageDispatchReplayClaim[] = [];
try {
if (shouldSkipUpdate(event.ctxForDedupe)) {
return;
return { kind: "ignored" };
}
const gate = await authorizeInboundMessage({
msg: event.msg,
@@ -164,7 +178,7 @@ export function registerTelegramMessageHandlers(
dmAccess: "challenge",
});
if (!gate.allowed) {
return;
return { kind: "ignored" };
}
const { effectiveDmAllow } = gate;
const {
@@ -200,11 +214,11 @@ export function registerTelegramMessageHandlers(
const dispatchDedupe = await claimMessageDispatchDedupe(event.msg, event.botUserId);
if (!dispatchDedupe.process) {
return;
return { kind: "ignored" };
}
dispatchDedupeClaims = dispatchDedupe.claims;
await recordMessageForReplyChain(event.msg, gate.context.threadSpec, event.botUserId);
await processInboundMessage({
return await processInboundMessage({
authorizationCfg: gate.context.cfg,
ctx: event.ctx,
msg: event.msg,
@@ -234,7 +248,7 @@ export function registerTelegramMessageHandlers(
// Spooled replays are durably retried; live updates get one apology
// because they are acked without replay.
if (spooledReplay) {
return;
return { kind: "ignored" };
}
await withTelegramApiErrorLogging({
operation: "sendMessage",
@@ -252,13 +266,14 @@ export function registerTelegramMessageHandlers(
),
}).catch(() => {});
}
return { kind: "ignored" };
}
};
bot.on("message", async (ctx) => {
const handleMessage = async (ctx: Context): Promise<TelegramInboundDisposition> => {
const msg = ctx.message;
if (!msg) {
return;
return { kind: "ignored" };
}
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
const isForum = await resolveTelegramForumFlag({
@@ -274,9 +289,9 @@ export function registerTelegramMessageHandlers(
// Bot-authored message updates can be echoed back by Telegram. Skip them here
// and rely on the dedicated channel_post handler for channel-originated posts.
if (normalizedMsg.from?.id != null && normalizedMsg.from.id === botUserId) {
return;
return { kind: "ignored" };
}
await handleInboundMessageLike({
return await handleInboundMessageLike({
ctxForDedupe: ctx,
ctx: buildSyntheticContext(ctx, normalizedMsg),
botUserId,
@@ -292,12 +307,12 @@ export function registerTelegramMessageHandlers(
oversizeLogMessage: "media exceeds size limit",
errorMessage: "handler failed",
});
});
};
bot.on("edited_message", async (ctx) => {
const handleEditedMessage = async (ctx: Context): Promise<TelegramInboundDisposition> => {
const msg = ctx.editedMessage;
if (!msg) {
return;
return { kind: "ignored" };
}
await recordEditedMessageForReplyChain({
ctxForDedupe: ctx,
@@ -305,21 +320,19 @@ export function registerTelegramMessageHandlers(
requireConfiguredGroup: false,
botUserId: resolveBotUserId(ctx),
});
});
return { kind: "recorded" };
};
// Handle channel posts — enables bot-to-bot communication via Telegram channels.
// Telegram bots cannot see other bot messages in groups, but CAN in channels.
// This handler normalizes channel_post updates into the standard message pipeline.
bot.on("channel_post", async (ctx) => {
const handleChannelPost = async (ctx: Context): Promise<TelegramInboundDisposition> => {
const post = ctx.channelPost;
if (!post) {
return;
return { kind: "ignored" };
}
const chatId = post.chat.id;
const syntheticMsg = normalizeChannelPostMessage(post);
await handleInboundMessageLike({
return await handleInboundMessageLike({
ctxForDedupe: ctx,
ctx: buildSyntheticContext(ctx, syntheticMsg),
botUserId: resolveBotUserId(ctx),
@@ -339,12 +352,12 @@ export function registerTelegramMessageHandlers(
oversizeLogMessage: "channel post media exceeds size limit",
errorMessage: "channel_post handler failed",
});
});
};
bot.on("edited_channel_post", async (ctx) => {
const handleEditedChannelPost = async (ctx: Context): Promise<TelegramInboundDisposition> => {
const post = ctx.editedChannelPost;
if (!post) {
return;
return { kind: "ignored" };
}
await recordEditedMessageForReplyChain({
ctxForDedupe: ctx,
@@ -352,5 +365,55 @@ export function registerTelegramMessageHandlers(
requireConfiguredGroup: true,
botUserId: resolveBotUserId(ctx),
});
});
return { kind: "recorded" };
};
return { handleMessage, handleEditedMessage, handleChannelPost, handleEditedChannelPost };
}
export function createTelegramInboundPipeline({
params,
message,
authorization,
}: {
params: RegisterTelegramHandlerParams;
message: TelegramMessagePipeline;
authorization: TelegramHandlerAuthorization;
}): TelegramInboundPipeline {
const handlers = createTelegramInboundHandlers(
params,
message,
authorization,
createTelegramInboundProcessing({ params, message }),
);
return {
handle: async (ctx) => {
if (ctx.message) {
return await handlers.handleMessage(ctx);
}
if (ctx.editedMessage) {
return await handlers.handleEditedMessage(ctx);
}
if (ctx.channelPost) {
return await handlers.handleChannelPost(ctx);
}
if (ctx.editedChannelPost) {
return await handlers.handleEditedChannelPost(ctx);
}
return { kind: "ignored" };
},
};
}
export function registerTelegramInboundHandlers({
bot,
pipeline,
}: {
bot: RegisterTelegramHandlerParams["bot"];
pipeline: TelegramInboundPipeline;
}): void {
bot.on("message", pipeline.handle);
bot.on("edited_message", pipeline.handle);
bot.on("channel_post", pipeline.handle);
bot.on("edited_channel_post", pipeline.handle);
}
@@ -1,4 +1,3 @@
// Telegram inbound buffering, media resolution, and message dispatch.
import type { Message } from "grammy/types";
import { isAbortRequestText } from "openclaw/plugin-sdk/command-primitives-runtime";
import type {
@@ -14,19 +13,21 @@ import {
buildTelegramInboundDebounceKey,
} from "./bot-handlers.debounce-key.js";
import {
createTelegramInboundDebounceRuntime,
createTelegramInboundBuffers,
type TelegramDebounceEntry,
} from "./bot-handlers.inbound-debounce.runtime.js";
import { createTelegramInboundMediaGroupRuntime } from "./bot-handlers.inbound-media-group.runtime.js";
import { createTelegramInboundTextRuntime } from "./bot-handlers.inbound-text.runtime.js";
} from "./bot-handlers.inbound-buffer.js";
import { createTelegramInboundMedia } from "./bot-handlers.inbound-media.js";
import {
isDurablyRetryableInboundMediaError,
isMediaSizeLimitError,
TelegramBotApiFileTooLargeError,
} from "./bot-handlers.media.js";
import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js";
import type { TelegramMessagePipeline } from "./bot-handlers.message-pipeline.js";
import type {
RegisterTelegramHandlerParams,
TelegramInboundDisposition,
} from "./bot-handlers.types.js";
import type { TelegramAmbientTranscriptWatermark } from "./bot-message-context.types.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import {
isTelegramSpooledReplayUpdate,
recordTelegramMessageProcessingResult,
@@ -42,8 +43,35 @@ import type { TelegramContext } from "./bot/types.js";
import { resolveTelegramCommandIngressAuthorization } from "./ingress.js";
import type { TelegramMessageDispatchReplayClaim } from "./message-dispatch-dedupe.js";
export function createTelegramHandlerInboundRuntime(
{
export interface TelegramInboundProcessing {
processInboundMessage: (params: TelegramInboundMessage) => Promise<TelegramInboundDisposition>;
}
type TelegramInboundMessage = {
authorizationCfg: OpenClawConfig;
ctx: TelegramContext;
msg: Message;
chatId: number;
isGroup: boolean;
isForum: boolean;
resolvedThreadId?: number;
dmThreadId?: number;
dmPolicy: DmPolicy;
storeAllowFrom: string[];
senderId: string;
effectiveGroupAllow: NormalizedAllowFrom;
effectiveDmAllow: NormalizedAllowFrom;
groupConfig?: TelegramGroupConfig;
topicConfig?: TelegramTopicConfig;
sendOversizeWarning: boolean;
oversizeLogMessage: string;
promptContextMinTimestampMs?: number;
promptContextAmbientWatermark?: TelegramAmbientTranscriptWatermark;
dispatchDedupeClaims: TelegramMessageDispatchReplayClaim[];
};
export function createTelegramInboundProcessing({
params: {
cfg,
accountId,
bot,
@@ -53,64 +81,43 @@ export function createTelegramHandlerInboundRuntime(
logger,
resolveGroupActivation,
resolveGroupRequireMention,
}: RegisterTelegramHandlerParams,
messageRuntime: TelegramHandlerMessageRuntime,
) {
},
message,
}: {
params: RegisterTelegramHandlerParams;
message: TelegramMessagePipeline;
}): TelegramInboundProcessing {
const {
resolveMediaRuntime,
recordMessageResolvedMedia,
promptContextBoundaryOptions,
releaseDispatchDedupeClaims,
createSpooledReplayParticipantForBufferedWork,
} = messageRuntime;
} = message;
const {
inboundDebouncer,
resolveTelegramDebounceEntryMs,
shouldDebounceTelegramEntry,
resolveTelegramDebounceLane,
} = createTelegramInboundDebounceRuntime({ cfg, bot, runtime }, messageRuntime);
handleTextFragment,
} = createTelegramInboundBuffers({ params: { cfg, bot, runtime, opts }, message });
const { handleMediaGroup, resolveUnaddressedGroupMediaDisposition } =
createTelegramInboundMediaGroupRuntime(
{
accountId,
bot,
opts,
runtime,
mediaMaxBytes,
logger,
resolveGroupActivation,
resolveGroupRequireMention,
},
messageRuntime,
);
const { handleTextFragment } = createTelegramInboundTextRuntime(
{ opts, runtime },
messageRuntime,
);
const processInboundMessage = async (params: {
authorizationCfg: OpenClawConfig;
ctx: TelegramContext;
msg: Message;
chatId: number;
isGroup: boolean;
isForum: boolean;
resolvedThreadId?: number;
dmThreadId?: number;
dmPolicy: DmPolicy;
storeAllowFrom: string[];
senderId: string;
effectiveGroupAllow: NormalizedAllowFrom;
effectiveDmAllow: NormalizedAllowFrom;
groupConfig?: TelegramGroupConfig;
topicConfig?: TelegramTopicConfig;
sendOversizeWarning: boolean;
oversizeLogMessage: string;
promptContextMinTimestampMs?: number;
promptContextAmbientWatermark?: TelegramAmbientTranscriptWatermark;
dispatchDedupeClaims: TelegramMessageDispatchReplayClaim[];
}) => {
const { handleMediaGroup, resolveUnaddressedGroupMediaDisposition } = createTelegramInboundMedia({
params: {
accountId,
bot,
opts,
runtime,
mediaMaxBytes,
logger,
resolveGroupActivation,
resolveGroupRequireMention,
},
message,
});
const processInboundMessage = async (
params: TelegramInboundMessage,
): Promise<TelegramInboundDisposition> => {
const {
authorizationCfg,
ctx,
@@ -177,10 +184,9 @@ export function createTelegramHandlerInboundRuntime(
dispatchDedupeClaims,
})
) {
return;
return { kind: "buffered", buffer: "text-fragment" };
}
// Media group handling - buffer multi-image messages
if (
handleMediaGroup({
authorizationCfg,
@@ -202,7 +208,7 @@ export function createTelegramHandlerInboundRuntime(
dispatchDedupeClaims,
})
) {
return;
return { kind: "buffered", buffer: "media-group" };
}
const mediaDisposition = await resolveUnaddressedGroupMediaDisposition({
@@ -222,7 +228,7 @@ export function createTelegramHandlerInboundRuntime(
});
if (mediaDisposition === "skip") {
releaseDispatchDedupeClaims(dispatchDedupeClaims);
return;
return { kind: "ignored" };
}
const nativeMedia = resolveTelegramPrimaryMedia(msg);
@@ -248,7 +254,7 @@ export function createTelegramHandlerInboundRuntime(
// drop the message during shutdown or deadline cancellation.
recordTelegramMessageProcessingResult({ kind: "failed-retryable", error: mediaErr });
releaseDispatchDedupeClaims(dispatchDedupeClaims, mediaErr);
return;
return { kind: "ignored" };
}
if (isMediaSizeLimitError(mediaErr)) {
if (sendOversizeWarning && mediaDisposition !== "silent-ingest") {
@@ -276,7 +282,7 @@ export function createTelegramHandlerInboundRuntime(
if (retryable && replayingSpooledUpdate) {
recordTelegramMessageProcessingResult({ kind: "failed-retryable", error: mediaErr });
releaseDispatchDedupeClaims(dispatchDedupeClaims, mediaErr);
return;
return { kind: "ignored" };
}
if (mediaDisposition !== "silent-ingest") {
await withTelegramApiErrorLogging({
@@ -344,19 +350,19 @@ export function createTelegramHandlerInboundRuntime(
...promptContextBoundaryOptions(promptContextMinTimestampMs, promptContextAmbientWatermark),
dispatchDedupeClaims,
};
if (
const shouldBufferDebounce = Boolean(
debounceEntry.debounceKey &&
resolveTelegramDebounceEntryMs(debounceEntry) > 0 &&
shouldDebounceTelegramEntry(debounceEntry)
) {
shouldDebounceTelegramEntry(debounceEntry),
);
if (shouldBufferDebounce) {
debounceEntry.spooledReplayParticipant = createSpooledReplayParticipantForBufferedWork(
`inbound-debounce:${debounceEntry.debounceKey}`,
);
}
await inboundDebouncer.enqueue(debounceEntry);
return shouldBufferDebounce ? { kind: "buffered", buffer: "debounce" } : { kind: "processed" };
};
return { processInboundMessage };
}
export type TelegramHandlerInboundRuntime = ReturnType<typeof createTelegramHandlerInboundRuntime>;
@@ -1,217 +0,0 @@
// Telegram long-text fragment buffering and ordered flush.
import type { Message } from "grammy/types";
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js";
import type { TelegramAmbientTranscriptWatermark } from "./bot-message-context.types.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import type { TelegramSpooledReplayDeferredParticipant } from "./bot-processing-outcome.js";
import { joinTelegramTextParts } from "./bot/helpers.js";
import type { TelegramContext } from "./bot/types.js";
import type { TelegramMessageDispatchReplayClaim } from "./message-dispatch-dedupe.js";
type TextFragmentEntry = {
key: string;
storeAllowFrom: string[];
messages: Array<{ msg: Message; ctx: TelegramContext; receivedAtMs: number }>;
promptContextMinTimestampMs?: number;
promptContextAmbientWatermark?: TelegramAmbientTranscriptWatermark;
dispatchDedupeClaims: TelegramMessageDispatchReplayClaim[];
spooledReplayParticipants: TelegramSpooledReplayDeferredParticipant[];
timer: ReturnType<typeof setTimeout>;
};
type TelegramTextFragmentInput = {
ctx: TelegramContext;
msg: Message;
chatId: number;
resolvedThreadId?: number;
dmThreadId?: number;
storeAllowFrom: string[];
isAbortControlMessage: boolean;
isAuthorizedAbortControlMessage: () => Promise<boolean>;
promptContextMinTimestampMs?: number;
promptContextAmbientWatermark?: TelegramAmbientTranscriptWatermark;
dispatchDedupeClaims: TelegramMessageDispatchReplayClaim[];
};
export function createTelegramInboundTextRuntime(
{ opts, runtime }: Pick<RegisterTelegramHandlerParams, "opts" | "runtime">,
messageRuntime: TelegramHandlerMessageRuntime,
) {
const {
promptContextBoundaryOptions,
latestPromptContextMinTimestampMs,
latestPromptContextAmbientWatermark,
mergeDispatchDedupeClaims,
releaseDispatchDedupeClaims,
buildFailedProcessingResult,
settleSpooledReplayParticipants,
createSpooledReplayParticipantForBufferedWork,
spooledReplayOptions,
buildSyntheticTextMessage,
buildSyntheticContext,
formatTelegramAmbientTranscriptBody,
processMessageWithReplyChain,
} = messageRuntime;
const maxGapMs =
typeof opts.testTimings?.textFragmentGapMs === "number" &&
Number.isFinite(opts.testTimings.textFragmentGapMs)
? Math.max(10, Math.floor(opts.testTimings.textFragmentGapMs))
: 1500;
const buffer = new Map<string, TextFragmentEntry>();
const queue = new KeyedAsyncQueue();
const flush = async (entry: TextFragmentEntry) => {
try {
entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id);
const first = entry.messages[0];
const last = entry.messages.at(-1);
if (!first || !last) {
releaseDispatchDedupeClaims(entry.dispatchDedupeClaims);
settleSpooledReplayParticipants(entry.spooledReplayParticipants, { kind: "skipped" });
return;
}
const combinedTextParts = joinTelegramTextParts(
entry.messages.map((message) => message.msg),
"",
);
const combinedText = combinedTextParts.text;
if (!combinedText.trim()) {
releaseDispatchDedupeClaims(entry.dispatchDedupeClaims);
settleSpooledReplayParticipants(entry.spooledReplayParticipants, { kind: "skipped" });
return;
}
const syntheticMessage = buildSyntheticTextMessage({
base: first.msg,
text: combinedText,
entities: combinedTextParts.entities,
date: last.msg.date ?? first.msg.date,
});
const result = await processMessageWithReplyChain({
ctx: buildSyntheticContext(first.ctx, syntheticMessage),
msg: syntheticMessage,
allMedia: [],
storeAllowFrom: entry.storeAllowFrom,
options: {
messageIdOverride: String(last.msg.message_id),
ambientTranscriptBody: formatTelegramAmbientTranscriptBody(
entry.messages.map((message) => message.msg),
),
receivedAtMs: first.receivedAtMs,
ingressBuffer: "text-fragment",
...promptContextBoundaryOptions(
entry.promptContextMinTimestampMs,
entry.promptContextAmbientWatermark,
),
...spooledReplayOptions(entry.spooledReplayParticipants),
},
dispatchDedupeClaims: entry.dispatchDedupeClaims,
spooledReplayParticipants: entry.spooledReplayParticipants,
});
settleSpooledReplayParticipants(entry.spooledReplayParticipants, result);
} catch (error) {
releaseDispatchDedupeClaims(entry.dispatchDedupeClaims, error);
settleSpooledReplayParticipants(
entry.spooledReplayParticipants,
buildFailedProcessingResult(error),
);
runtime.error?.(danger(`text fragment handler failed: ${String(error)}`));
}
};
const queueFlush = async (entry: TextFragmentEntry) => {
await queue.enqueue(entry.key, async () => {
await flush(entry).catch(() => undefined);
});
};
const runFlush = async (entry: TextFragmentEntry) => {
buffer.delete(entry.key);
await queueFlush(entry);
};
const scheduleFlush = (entry: TextFragmentEntry) => {
clearTimeout(entry.timer);
entry.timer = setTimeout(() => void runFlush(entry), maxGapMs);
};
const handleTextFragment = async (params: TelegramTextFragmentInput): Promise<boolean> => {
const text = typeof params.msg.text === "string" ? params.msg.text : undefined;
const isCommandLike = (text ?? "").trim().startsWith("/");
const senderId = params.msg.from?.id != null ? String(params.msg.from.id) : "unknown";
const threadId = params.resolvedThreadId ?? params.dmThreadId;
const key = `text:${params.chatId}:${threadId ?? "main"}:${senderId}`;
if (text && !isCommandLike && !params.isAbortControlMessage) {
const nowMs = Date.now();
const existing = buffer.get(key);
if (existing) {
const last = existing.messages.at(-1);
const idGap = last ? params.msg.message_id - last.msg.message_id : Infinity;
const timeGapMs = nowMs - (last?.receivedAtMs ?? nowMs);
const canAppend = idGap > 0 && idGap <= 1 && timeGapMs >= 0 && timeGapMs <= maxGapMs;
const nextTotalChars =
existing.messages.reduce((sum, message) => sum + (message.msg.text?.length ?? 0), 0) +
text.length;
if (canAppend && existing.messages.length < 12 && nextTotalChars <= 50_000) {
const participant = createSpooledReplayParticipantForBufferedWork(
`text-fragment:${key}:${params.msg.message_id}`,
);
if (participant) {
existing.spooledReplayParticipants.push(participant);
}
existing.messages.push({ msg: params.msg, ctx: params.ctx, receivedAtMs: nowMs });
existing.promptContextMinTimestampMs = latestPromptContextMinTimestampMs(
existing.promptContextMinTimestampMs,
params.promptContextMinTimestampMs,
);
existing.promptContextAmbientWatermark = latestPromptContextAmbientWatermark(
existing.promptContextAmbientWatermark,
params.promptContextAmbientWatermark,
);
existing.dispatchDedupeClaims = mergeDispatchDedupeClaims(
existing.dispatchDedupeClaims,
params.dispatchDedupeClaims,
);
scheduleFlush(existing);
return true;
}
clearTimeout(existing.timer);
buffer.delete(key);
await queueFlush(existing);
}
if (text.length >= 4000) {
const participant = createSpooledReplayParticipantForBufferedWork(
`text-fragment:${key}:${params.msg.message_id}`,
);
const entry: TextFragmentEntry = {
key,
storeAllowFrom: params.storeAllowFrom,
messages: [{ msg: params.msg, ctx: params.ctx, receivedAtMs: nowMs }],
dispatchDedupeClaims: params.dispatchDedupeClaims,
spooledReplayParticipants: participant ? [participant] : [],
...promptContextBoundaryOptions(
params.promptContextMinTimestampMs,
params.promptContextAmbientWatermark,
),
timer: setTimeout(() => {}, maxGapMs),
};
buffer.set(key, entry);
scheduleFlush(entry);
return true;
}
} else if (
text &&
params.isAbortControlMessage &&
(await params.isAuthorizedAbortControlMessage())
) {
const existing = buffer.get(key);
if (existing) {
clearTimeout(existing.timer);
buffer.delete(key);
releaseDispatchDedupeClaims(existing.dispatchDedupeClaims);
settleSpooledReplayParticipants(existing.spooledReplayParticipants, { kind: "skipped" });
}
}
return false;
};
return { handleTextFragment };
}
@@ -2,8 +2,8 @@
import type { Message } from "grammy/types";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it } from "vitest";
import { createTelegramMessageContextRuntime } from "./bot-handlers.message-context.runtime.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import { createTelegramMessageContextRuntime } from "./bot-handlers.message-context.js";
import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js";
import { resetTelegramMessageCacheForTest } from "./runtime.test-support.js";
const CHAT_ID = 5678;
@@ -1,17 +1,38 @@
// Telegram reply-chain cache and prompt-context projection.
import type { Message } from "grammy/types";
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
import { formatMediaPlaceholderText } from "openclaw/plugin-sdk/channel-inbound";
import { resolveStoredModelOverride } from "openclaw/plugin-sdk/command-auth-native";
import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts";
import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history";
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
import {
getSessionEntry,
readAmbientTranscriptWatermark,
resolveAmbientTranscriptWatermarkKey,
type SessionEntry,
} from "openclaw/plugin-sdk/session-store-runtime";
import { stripInlineDirectiveTagsForDelivery } from "openclaw/plugin-sdk/text-chunking";
import { resolveDefaultModelForAgent } from "./bot-handlers.agent.runtime.js";
import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js";
import type { TelegramMediaRef } from "./bot-message-context.js";
import type {
TelegramAmbientTranscriptWatermark,
TelegramMessageContextOptions,
TelegramPromptContextEntry,
} from "./bot-message-context.types.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import type { TelegramThreadSpec } from "./bot/helpers.js";
import {
buildSenderName,
getTelegramTextParts,
resolveTelegramPrimaryMedia,
resolveTelegramForumThreadId,
shouldUseTelegramDmThreadSession,
type TelegramThreadSpec,
} from "./bot/helpers.js";
import type { TelegramContext } from "./bot/types.js";
import {
resolveTelegramConversationBaseSessionKey,
resolveTelegramConversationRoute,
} from "./conversation-route.js";
import { resolveTelegramDmHistoryLimit } from "./dm-history.js";
import {
buildTelegramSelfSenderName,
@@ -51,6 +72,229 @@ function legacyAssistantTextKey(node: TelegramCachedMessageNode, botUserId?: num
export type TelegramPromptContextMessageSelection = ReadonlyMap<string, "include" | "exclude">;
export type TelegramSessionState = {
agentId: string;
sessionEntry: SessionEntry | undefined;
sessionKey: string;
storePath: string;
model: string | undefined;
};
export type ResolveTelegramSessionStateParams = {
chatId: number | string;
isGroup: boolean;
isForum: boolean;
messageThreadId?: number;
resolvedThreadId?: number;
botHasTopicsEnabled?: boolean;
senderId?: string | number;
runtimeCfg: OpenClawConfig;
};
export type ResolvePromptContextAmbientWatermarkParams = {
chatId: number | string;
isGroup: boolean;
resolvedThreadId?: number;
sessionKey: string;
storePath: string;
};
export const normalizePromptContextMinTimestampMs = (timestampMs?: number) =>
typeof timestampMs === "number" && Number.isFinite(timestampMs) ? timestampMs : undefined;
export function promptContextBoundaryOptions(
timestampMs?: number,
ambientWatermark?: TelegramAmbientTranscriptWatermark,
): Pick<
TelegramMessageContextOptions,
"promptContextMinTimestampMs" | "promptContextAmbientWatermark"
> {
const promptContextMinTimestampMs = normalizePromptContextMinTimestampMs(timestampMs);
return {
...(promptContextMinTimestampMs === undefined ? {} : { promptContextMinTimestampMs }),
...(ambientWatermark === undefined ? {} : { promptContextAmbientWatermark: ambientWatermark }),
};
}
export function latestPromptContextMinTimestampMs(
...timestamps: Array<number | undefined>
): number | undefined {
let latest: number | undefined;
for (const timestampMs of timestamps) {
const normalized = normalizePromptContextMinTimestampMs(timestampMs);
if (normalized !== undefined) {
latest = latest === undefined ? normalized : Math.max(latest, normalized);
}
}
return latest;
}
export const latestPromptContextAmbientWatermark = (
...watermarks: Array<TelegramAmbientTranscriptWatermark | undefined>
): TelegramAmbientTranscriptWatermark | undefined =>
watermarks.findLast((watermark) => watermark !== undefined);
export function buildSyntheticTextMessage(params: {
base: Message.ServiceMessage;
text: string;
entities?: Message["entities"];
date?: number;
from?: Message["from"];
}): Message {
return {
...params.base,
...(params.from ? { from: params.from } : {}),
text: params.text,
caption: undefined,
caption_entities: undefined,
entities: params.entities?.length ? params.entities : undefined,
...(params.date != null ? { date: params.date } : {}),
};
}
export const buildSyntheticContext = (
ctx: Pick<TelegramContext, "me" | "getFile">,
message: Message,
): TelegramContext => ({ message, me: ctx.me, getFile: ctx.getFile.bind(ctx) });
export function formatTelegramAmbientTranscriptBody(
messages: readonly Message[],
): string | undefined {
const lines = messages.map((msg) => {
const text = getTelegramTextParts(msg).text.trim();
const media = resolveTelegramPrimaryMedia(msg);
const body = text || formatMediaPlaceholderText(media ? [{ kind: media.kind }] : [{}]);
const messageId = msg.message_id ? `#${msg.message_id}` : undefined;
const sender = buildSenderName(msg);
const prefix = [messageId, sender].filter(Boolean).join(" ");
return prefix ? `${prefix}: ${body}` : body;
});
return lines.length > 0 ? lines.join("\n") : undefined;
}
export function createTelegramMessageSessionRuntime({
accountId,
resolveTelegramGroupConfig,
telegramDeps,
}: Pick<
RegisterTelegramHandlerParams,
"accountId" | "resolveTelegramGroupConfig" | "telegramDeps"
>) {
const loadSessionEntry = telegramDeps.getSessionEntry ?? getSessionEntry;
const resolveTelegramSessionState = (
params: ResolveTelegramSessionStateParams,
): TelegramSessionState => {
const resolvedThreadId =
params.resolvedThreadId ??
resolveTelegramForumThreadId({
isForum: params.isForum,
messageThreadId: params.messageThreadId,
});
const dmThreadId = !params.isGroup ? params.messageThreadId : undefined;
const topicThreadId = resolvedThreadId ?? dmThreadId;
const { topicConfig } = resolveTelegramGroupConfig(
params.chatId,
topicThreadId,
params.runtimeCfg,
);
const { route } = resolveTelegramConversationRoute({
cfg: params.runtimeCfg,
accountId,
chatId: params.chatId,
isGroup: params.isGroup,
resolvedThreadId,
replyThreadId: topicThreadId,
senderId: params.senderId,
topicAgentId: topicConfig?.agentId,
});
const baseSessionKey = resolveTelegramConversationBaseSessionKey({
cfg: params.runtimeCfg,
route,
chatId: params.chatId,
isGroup: params.isGroup,
senderId: params.senderId,
});
const threadKeys =
shouldUseTelegramDmThreadSession({
dmThreadId,
botHasTopicsEnabled: params.botHasTopicsEnabled,
}) && dmThreadId != null
? resolveThreadSessionKeys({
baseSessionKey,
threadId: `${params.chatId}:${dmThreadId}`,
})
: null;
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
const storePath = telegramDeps.resolveStorePath(params.runtimeCfg.session?.store, {
agentId: route.agentId,
});
const entry = loadSessionEntry({ storePath, sessionKey });
const storedOverride = resolveStoredModelOverride({
sessionEntry: entry,
loadSessionEntry: (parentSessionKey) =>
loadSessionEntry({ storePath, sessionKey: parentSessionKey }),
sessionKey,
defaultProvider: resolveDefaultModelForAgent({
cfg: params.runtimeCfg,
agentId: route.agentId,
}).provider,
});
if (storedOverride) {
return {
agentId: route.agentId,
sessionEntry: entry,
sessionKey,
storePath,
model: storedOverride.provider
? `${storedOverride.provider}/${storedOverride.model}`
: storedOverride.model,
};
}
const provider = entry?.modelProvider?.trim();
const model = entry?.model?.trim();
if (provider && model) {
return {
agentId: route.agentId,
sessionEntry: entry,
sessionKey,
storePath,
model: `${provider}/${model}`,
};
}
const modelCfg = params.runtimeCfg.agents?.defaults?.model;
return {
agentId: route.agentId,
sessionEntry: entry,
sessionKey,
storePath,
model: typeof modelCfg === "string" ? modelCfg : modelCfg?.primary,
};
};
const resolvePromptContextAmbientWatermark = (
params: ResolvePromptContextAmbientWatermarkParams,
): TelegramAmbientTranscriptWatermark | undefined => {
if (!params.isGroup) {
return undefined;
}
const key = (
telegramDeps.resolveAmbientTranscriptWatermarkKey ?? resolveAmbientTranscriptWatermarkKey
)({
channel: "telegram",
accountId,
conversationId: String(params.chatId),
...(params.resolvedThreadId !== undefined ? { threadId: params.resolvedThreadId } : {}),
});
return (telegramDeps.readAmbientTranscriptWatermark ?? readAmbientTranscriptWatermark)({
storePath: params.storePath,
sessionKey: params.sessionKey,
key,
});
};
return { resolveTelegramSessionState, resolvePromptContextAmbientWatermark };
}
export function createTelegramMessageContextRuntime({
cfg,
accountId,
@@ -1,200 +0,0 @@
// Telegram dispatch dedupe, replay settlement, and synthetic-message helpers.
import type { Message } from "grammy/types";
import { formatMediaPlaceholderText } from "openclaw/plugin-sdk/channel-inbound";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type {
TelegramAmbientTranscriptWatermark,
TelegramMessageContextOptions,
} from "./bot-message-context.types.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import {
createTelegramSpooledReplayDeferredParticipant,
type TelegramMessageProcessingResult,
type TelegramSpooledReplayDeferredParticipant,
type TelegramSpooledReplaySettlementHold,
} from "./bot-processing-outcome.js";
import {
buildSenderName,
getTelegramTextParts,
resolveTelegramPrimaryMedia,
} from "./bot/helpers.js";
import type { TelegramContext } from "./bot/types.js";
import {
claimTelegramMessageDispatchReplay,
commitTelegramMessageDispatchReplay,
createTelegramMessageDispatchReplayGuard,
releaseTelegramMessageDispatchReplay,
type TelegramMessageDispatchReplayClaim,
} from "./message-dispatch-dedupe.js";
export function createTelegramMessageLifecycleRuntime({
accountId,
runtime,
}: Pick<RegisterTelegramHandlerParams, "accountId" | "runtime">) {
const replayGuard = createTelegramMessageDispatchReplayGuard({
onDiskError: (error) => {
runtime.error?.(danger(`[telegram] message dispatch dedupe store failed: ${String(error)}`));
},
});
const normalizePromptContextMinTimestampMs = (timestampMs?: number) =>
typeof timestampMs === "number" && Number.isFinite(timestampMs) ? timestampMs : undefined;
const promptContextBoundaryOptions = (
timestampMs?: number,
ambientWatermark?: TelegramAmbientTranscriptWatermark,
): Pick<
TelegramMessageContextOptions,
"promptContextMinTimestampMs" | "promptContextAmbientWatermark"
> => {
const promptContextMinTimestampMs = normalizePromptContextMinTimestampMs(timestampMs);
return {
...(promptContextMinTimestampMs === undefined ? {} : { promptContextMinTimestampMs }),
...(ambientWatermark === undefined
? {}
: { promptContextAmbientWatermark: ambientWatermark }),
};
};
const latestPromptContextMinTimestampMs = (
...timestamps: Array<number | undefined>
): number | undefined => {
let latest: number | undefined;
for (const timestampMs of timestamps) {
const normalized = normalizePromptContextMinTimestampMs(timestampMs);
if (normalized !== undefined) {
latest = latest === undefined ? normalized : Math.max(latest, normalized);
}
}
return latest;
};
const latestPromptContextAmbientWatermark = (
...watermarks: Array<TelegramAmbientTranscriptWatermark | undefined>
): TelegramAmbientTranscriptWatermark | undefined =>
watermarks.findLast((watermark) => watermark !== undefined);
const mergeDispatchDedupeClaims = (
...groups: Array<readonly TelegramMessageDispatchReplayClaim[] | undefined>
) => [...new Set(groups.flatMap((group) => group ?? []))];
const releaseDispatchDedupeClaims = (
claims: readonly TelegramMessageDispatchReplayClaim[],
error?: unknown,
) => {
releaseTelegramMessageDispatchReplay({ claims, error });
};
const commitDispatchDedupeClaims = async (
claims: readonly TelegramMessageDispatchReplayClaim[],
options: { requirePersistent?: boolean } = {},
) => {
await commitTelegramMessageDispatchReplay({ guard: replayGuard, claims, ...options });
};
const buildFailedProcessingResult = (error: unknown): TelegramMessageProcessingResult => ({
kind: "failed-retryable",
error,
});
const settleSpooledReplayParticipants = (
participants: readonly TelegramSpooledReplayDeferredParticipant[],
result: TelegramMessageProcessingResult,
) => {
for (const participant of new Set(participants)) {
participant.settle(result);
}
};
const beginSpooledReplaySettlementHolds = (
participants: readonly TelegramSpooledReplayDeferredParticipant[],
) => {
const holds: TelegramSpooledReplaySettlementHold[] = [];
for (const participant of new Set(participants)) {
const hold = participant.beginSettlementHold();
if (!hold) {
for (const acquired of holds) {
acquired.release("replay-pending");
}
const reason = participant.abortSignal.reason;
throw reason instanceof Error
? reason
: new Error(
`telegram spooled replay participant ${participant.key} settled before durable adoption`,
);
}
holds.push(hold);
}
return (mode: Parameters<TelegramSpooledReplaySettlementHold["release"]>[0]) => {
for (const hold of holds) {
hold.release(mode);
}
};
};
const createSpooledReplayParticipantForBufferedWork = (key: string) =>
createTelegramSpooledReplayDeferredParticipant(key) ?? undefined;
const spooledReplayOptions = (
participants: readonly TelegramSpooledReplayDeferredParticipant[],
): Pick<TelegramMessageContextOptions, "spooledReplay"> =>
participants.length > 0 ? { spooledReplay: true } : {};
const claimMessageDispatchDedupe = async (
msg: Message,
botUserId: number,
): Promise<
{ process: true; claims: TelegramMessageDispatchReplayClaim[] } | { process: false }
> => {
const claim = await claimTelegramMessageDispatchReplay({
guard: replayGuard,
accountId,
botUserId,
msg,
});
if (claim.kind === "duplicate") {
logVerbose(`telegram dispatch dedupe: skipped message ${msg.chat.id}:${msg.message_id}`);
return { process: false };
}
return { process: true, claims: claim.kind === "claimed" ? [claim.handle] : [] };
};
const buildSyntheticTextMessage = (params: {
base: Message.ServiceMessage;
text: string;
entities?: Message["entities"];
date?: number;
from?: Message["from"];
}): Message => ({
...params.base,
...(params.from ? { from: params.from } : {}),
text: params.text,
caption: undefined,
caption_entities: undefined,
entities: params.entities?.length ? params.entities : undefined,
...(params.date != null ? { date: params.date } : {}),
});
const buildSyntheticContext = (
ctx: Pick<TelegramContext, "me" | "getFile">,
message: Message,
): TelegramContext => ({ message, me: ctx.me, getFile: ctx.getFile.bind(ctx) });
const formatTelegramAmbientTranscriptBody = (
messages: readonly Message[],
): string | undefined => {
const lines = messages.map((msg) => {
const text = getTelegramTextParts(msg).text.trim();
const media = resolveTelegramPrimaryMedia(msg);
const body = text || formatMediaPlaceholderText(media ? [{ kind: media.kind }] : [{}]);
const messageId = msg.message_id ? `#${msg.message_id}` : undefined;
const sender = buildSenderName(msg);
const prefix = [messageId, sender].filter(Boolean).join(" ");
return prefix ? `${prefix}: ${body}` : body;
});
return lines.length > 0 ? lines.join("\n") : undefined;
};
return {
normalizePromptContextMinTimestampMs,
promptContextBoundaryOptions,
latestPromptContextMinTimestampMs,
latestPromptContextAmbientWatermark,
mergeDispatchDedupeClaims,
releaseDispatchDedupeClaims,
commitDispatchDedupeClaims,
buildFailedProcessingResult,
settleSpooledReplayParticipants,
beginSpooledReplaySettlementHolds,
createSpooledReplayParticipantForBufferedWork,
spooledReplayOptions,
claimMessageDispatchDedupe,
buildSyntheticTextMessage,
buildSyntheticContext,
formatTelegramAmbientTranscriptBody,
};
}
@@ -1,6 +1,9 @@
import type { Message } from "grammy/types";
import { describe, expect, it } from "vitest";
import { createTelegramMessageLifecycleRuntime } from "./bot-handlers.message-lifecycle.runtime.js";
import {
buildSyntheticTextMessage,
formatTelegramAmbientTranscriptBody,
} from "./bot-handlers.message-context.js";
function message(fields: Record<string, unknown>): Message {
return {
@@ -13,13 +16,8 @@ function message(fields: Record<string, unknown>): Message {
}
describe("Telegram ambient transcript media text", () => {
const runtime = createTelegramMessageLifecycleRuntime({
accountId: "default",
runtime: { log: () => {}, error: () => {}, exit: () => {} } as never,
});
it("renders native media kinds for captionless transcript lines", () => {
const body = runtime.formatTelegramAmbientTranscriptBody([
const body = formatTelegramAmbientTranscriptBody([
message({
message_id: 7,
photo: [{ file_id: "photo-1", file_unique_id: "photo-u1", width: 1, height: 1 }],
@@ -30,7 +28,7 @@ describe("Telegram ambient transcript media text", () => {
});
it("preserves captions instead of appending media text", () => {
const body = runtime.formatTelegramAmbientTranscriptBody([
const body = formatTelegramAmbientTranscriptBody([
message({ message_id: 8, caption: "diagram", document: { file_id: "doc-1" } }),
]);
@@ -38,14 +36,14 @@ describe("Telegram ambient transcript media text", () => {
});
it("uses the formatter attachment fallback for media-less empty messages", () => {
const body = runtime.formatTelegramAmbientTranscriptBody([message({ message_id: 9 })]);
const body = formatTelegramAmbientTranscriptBody([message({ message_id: 9 })]);
expect(body).toBe("#9 Ada: <media:attachment>");
});
it("preserves combined formatting entities when building synthetic text messages", () => {
const entities = [{ type: "bold" as const, offset: 3, length: 4 }];
const synthetic = runtime.buildSyntheticTextMessage({
const synthetic = buildSyntheticTextMessage({
base: message({ caption: "old caption", caption_entities: entities }),
text: "😀 bold",
entities,
@@ -1,21 +1,37 @@
// Telegram message/session/prompt pipeline shared by bot handler registrars.
import type { Message } from "grammy/types";
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime";
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { evaluateSupplementalContextVisibility } from "openclaw/plugin-sdk/security-runtime";
import { expandTelegramAllowFromWithAccessGroups } from "./access-groups.js";
import { resolveTelegramAccount, resolveTelegramMediaRuntimeOptions } from "./accounts.js";
import {
resolveTelegramAccount,
resolveTelegramMediaRuntimeOptions,
type TelegramMediaRuntimeOptions,
} from "./accounts.js";
import { firstDefined, isSenderAllowed, normalizeAllowFrom } from "./bot-access.js";
import { hasInboundMedia, resolveInboundMediaFileId } from "./bot-handlers.media.js";
import {
buildSyntheticContext,
buildSyntheticTextMessage,
createTelegramMessageContextRuntime,
createTelegramMessageSessionRuntime,
formatTelegramAmbientTranscriptBody,
latestPromptContextAmbientWatermark,
latestPromptContextMinTimestampMs,
normalizePromptContextMinTimestampMs,
promptContextBoundaryOptions,
type ResolvePromptContextAmbientWatermarkParams,
type ResolveTelegramSessionStateParams,
type TelegramPromptContextMessageSelection,
} from "./bot-handlers.message-context.runtime.js";
import { createTelegramMessageLifecycleRuntime } from "./bot-handlers.message-lifecycle.runtime.js";
import { createTelegramMessageSessionRuntime } from "./bot-handlers.message-session.runtime.js";
type TelegramSessionState,
} from "./bot-handlers.message-context.js";
import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js";
import type { TelegramMediaRef } from "./bot-message-context.js";
import type { TelegramMessageContextOptions } from "./bot-message-context.types.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import type {
TelegramAmbientTranscriptWatermark,
TelegramMessageContextOptions,
} from "./bot-message-context.types.js";
import {
createTelegramSpooledReplayDeferredParticipant,
createTelegramSpooledReplayParticipant,
@@ -25,14 +41,21 @@ import {
recordTelegramMessageProcessingResult,
type TelegramMessageProcessingResult,
type TelegramSpooledReplayDeferredParticipant,
type TelegramSpooledReplaySettlementHold,
} from "./bot-processing-outcome.js";
import { resolveMedia } from "./bot/delivery.resolve-media.js";
import { resolveTelegramMessageThreadSpec } from "./bot/helpers.js";
import { resolveTelegramMessageThreadSpec, type TelegramThreadSpec } from "./bot/helpers.js";
import type { TelegramContext } from "./bot/types.js";
import { resolveTelegramScopedGroupConfig } from "./group-config-helpers.js";
import type { TelegramResolvedMedia } from "./message-cache-persistence.js";
import type { TelegramCachedMessageNode, TelegramReplyChainEntry } from "./message-cache.js";
import type { TelegramMessageDispatchReplayClaim } from "./message-dispatch-dedupe.js";
import {
claimTelegramMessageDispatchReplay,
commitTelegramMessageDispatchReplay,
createTelegramMessageDispatchReplayGuard,
releaseTelegramMessageDispatchReplay,
type TelegramMessageDispatchReplayClaim,
} from "./message-dispatch-dedupe.js";
import {
resolveTelegramInboundMediaUri,
resolveTelegramPromptMediaPath,
@@ -40,6 +63,76 @@ import {
const HOUR_MS = 60 * 60_000;
type TelegramProcessMessageWithReplyChainOptions = {
ctx: TelegramContext;
msg: Message;
allMedia: TelegramMediaRef[];
promptContextMessageSelection?: TelegramPromptContextMessageSelection;
storeAllowFrom: string[];
options?: TelegramMessageContextOptions;
dispatchDedupeClaims?: TelegramMessageDispatchReplayClaim[];
spooledReplayParticipants?: readonly TelegramSpooledReplayDeferredParticipant[];
spooledReplayAbortSignal?: AbortSignal;
};
export interface TelegramMessagePipeline {
resolveMediaRuntime: (
...explicitSignals: AbortSignal[]
) => TelegramMediaRuntimeOptions & { abortSignal: AbortSignal | undefined };
normalizePromptContextMinTimestampMs: typeof normalizePromptContextMinTimestampMs;
promptContextBoundaryOptions: typeof promptContextBoundaryOptions;
latestPromptContextMinTimestampMs: typeof latestPromptContextMinTimestampMs;
latestPromptContextAmbientWatermark: typeof latestPromptContextAmbientWatermark;
mergeDispatchDedupeClaims: (
...groups: Array<readonly TelegramMessageDispatchReplayClaim[] | undefined>
) => TelegramMessageDispatchReplayClaim[];
releaseDispatchDedupeClaims: (
claims: readonly TelegramMessageDispatchReplayClaim[],
error?: unknown,
) => void;
buildFailedProcessingResult: (error: unknown) => TelegramMessageProcessingResult;
settleSpooledReplayParticipants: (
participants: readonly TelegramSpooledReplayDeferredParticipant[],
result: TelegramMessageProcessingResult,
) => void;
createSpooledReplayParticipantForBufferedWork: (
key: string,
) => TelegramSpooledReplayDeferredParticipant | undefined;
spooledReplayOptions: (
participants: readonly TelegramSpooledReplayDeferredParticipant[],
) => Pick<TelegramMessageContextOptions, "spooledReplay">;
claimMessageDispatchDedupe: (
msg: Message,
botUserId: number,
) => Promise<
{ process: true; claims: TelegramMessageDispatchReplayClaim[] } | { process: false }
>;
buildSyntheticTextMessage: typeof buildSyntheticTextMessage;
buildSyntheticContext: typeof buildSyntheticContext;
formatTelegramAmbientTranscriptBody: typeof formatTelegramAmbientTranscriptBody;
resolveTelegramSessionState: (params: ResolveTelegramSessionStateParams) => TelegramSessionState;
resolvePromptContextAmbientWatermark: (
params: ResolvePromptContextAmbientWatermarkParams,
) => TelegramAmbientTranscriptWatermark | undefined;
recordMessageForReplyChain: (
msg: Message,
providerObservedThread?: TelegramThreadSpec,
botUserId?: number,
) => Promise<TelegramCachedMessageNode>;
recordMessageResolvedMedia: (params: {
msg: Message;
media: TelegramResolvedMedia;
botUserId?: number;
}) => Promise<void>;
resolveCachedMessageThreadSpec: (params: {
chatId: number | string;
messageId: number | string;
}) => Promise<TelegramThreadSpec | undefined>;
processMessageWithReplyChain: (
params: TelegramProcessMessageWithReplyChainOptions,
) => Promise<TelegramMessageProcessingResult>;
}
function resolveRetainedTelegramMedia(params: {
media?: TelegramResolvedMedia;
maxBytes: number;
@@ -65,7 +158,7 @@ function resolveRetainedTelegramMedia(params: {
: undefined;
}
export function createTelegramHandlerMessageRuntime({
export function createTelegramMessagePipeline({
cfg,
accountId,
bot,
@@ -78,7 +171,7 @@ export function createTelegramHandlerMessageRuntime({
processMessage,
logger,
telegramDeps,
}: RegisterTelegramHandlerParams) {
}: RegisterTelegramHandlerParams): TelegramMessagePipeline {
const { token } = opts;
const mediaRuntimeOptions = resolveTelegramMediaRuntimeOptions({
cfg,
@@ -121,24 +214,87 @@ export function createTelegramHandlerMessageRuntime({
telegramCfg,
telegramDeps,
});
const {
normalizePromptContextMinTimestampMs,
promptContextBoundaryOptions,
latestPromptContextMinTimestampMs,
latestPromptContextAmbientWatermark,
mergeDispatchDedupeClaims,
releaseDispatchDedupeClaims,
commitDispatchDedupeClaims,
buildFailedProcessingResult,
settleSpooledReplayParticipants,
beginSpooledReplaySettlementHolds,
createSpooledReplayParticipantForBufferedWork,
spooledReplayOptions,
claimMessageDispatchDedupe,
buildSyntheticTextMessage,
buildSyntheticContext,
formatTelegramAmbientTranscriptBody,
} = createTelegramMessageLifecycleRuntime({ accountId, runtime });
const replayGuard = createTelegramMessageDispatchReplayGuard({
onDiskError: (error) => {
runtime.error?.(danger(`[telegram] message dispatch dedupe store failed: ${String(error)}`));
},
});
const mergeDispatchDedupeClaims = (
...groups: Array<readonly TelegramMessageDispatchReplayClaim[] | undefined>
) => [...new Set(groups.flatMap((group) => group ?? []))];
const releaseDispatchDedupeClaims = (
claims: readonly TelegramMessageDispatchReplayClaim[],
error?: unknown,
) => {
releaseTelegramMessageDispatchReplay({ claims, error });
};
const commitDispatchDedupeClaims = async (
claims: readonly TelegramMessageDispatchReplayClaim[],
options: { requirePersistent?: boolean } = {},
) => {
await commitTelegramMessageDispatchReplay({ guard: replayGuard, claims, ...options });
};
const buildFailedProcessingResult = (error: unknown): TelegramMessageProcessingResult => ({
kind: "failed-retryable",
error,
});
const settleSpooledReplayParticipants = (
participants: readonly TelegramSpooledReplayDeferredParticipant[],
result: TelegramMessageProcessingResult,
) => {
for (const participant of new Set(participants)) {
participant.settle(result);
}
};
const beginSpooledReplaySettlementHolds = (
participants: readonly TelegramSpooledReplayDeferredParticipant[],
) => {
const holds: TelegramSpooledReplaySettlementHold[] = [];
for (const participant of new Set(participants)) {
const hold = participant.beginSettlementHold();
if (!hold) {
for (const acquired of holds) {
acquired.release("replay-pending");
}
const reason = participant.abortSignal.reason;
throw reason instanceof Error
? reason
: new Error(
`telegram spooled replay participant ${participant.key} settled before durable adoption`,
);
}
holds.push(hold);
}
return (mode: Parameters<TelegramSpooledReplaySettlementHold["release"]>[0]) => {
for (const hold of holds) {
hold.release(mode);
}
};
};
const createSpooledReplayParticipantForBufferedWork = (key: string) =>
createTelegramSpooledReplayDeferredParticipant(key) ?? undefined;
const spooledReplayOptions = (
participants: readonly TelegramSpooledReplayDeferredParticipant[],
): Pick<TelegramMessageContextOptions, "spooledReplay"> =>
participants.length > 0 ? { spooledReplay: true } : {};
const claimMessageDispatchDedupe = async (
msg: Message,
botUserId: number,
): Promise<
{ process: true; claims: TelegramMessageDispatchReplayClaim[] } | { process: false }
> => {
const claim = await claimTelegramMessageDispatchReplay({
guard: replayGuard,
accountId,
botUserId,
msg,
});
if (claim.kind === "duplicate") {
logVerbose(`telegram dispatch dedupe: skipped message ${msg.chat.id}:${msg.message_id}`);
return { process: false };
}
return { process: true, claims: claim.kind === "claimed" ? [claim.handle] : [] };
};
const resolveReplyMediaForChain = async (
ctx: TelegramContext,
@@ -413,11 +569,11 @@ export function createTelegramHandlerMessageRuntime({
promptContextMediaByMessageId,
params.promptContextMessageSelection,
);
const result = await processMessage(
params.ctx,
params.allMedia,
params.storeAllowFrom,
{
const result = await processMessage({
ctx: params.ctx,
allMedia: params.allMedia,
storeAllowFrom: params.storeAllowFrom,
turnContext: {
cfg: runtimeCfg,
telegramCfg: runtimeTelegramCfg,
onDispatchStart: async () => {
@@ -433,11 +589,11 @@ export function createTelegramHandlerMessageRuntime({
return await finalizeSpooledReplayResult(completed);
},
},
params.options,
options: params.options,
replyMedia,
replyChain,
promptContext,
);
});
if (spooledReplay) {
return await finalizeSpooledReplayResult(result);
}
@@ -482,5 +638,3 @@ export function createTelegramHandlerMessageRuntime({
processMessageWithReplyChain,
};
}
export type TelegramHandlerMessageRuntime = ReturnType<typeof createTelegramHandlerMessageRuntime>;
@@ -2,7 +2,7 @@
import type { SessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import { describe, expect, it, vi } from "vitest";
import type { TelegramBotDeps } from "./bot-deps.js";
import { createTelegramMessageSessionRuntime } from "./bot-handlers.message-session.runtime.js";
import { createTelegramMessageSessionRuntime } from "./bot-handlers.message-context.js";
describe("createTelegramMessageSessionRuntime", () => {
it("inherits a DM topic model override through keyed session loads", () => {
@@ -1,151 +0,0 @@
// Telegram conversation routing and session-state lookup for bot handlers.
import { resolveStoredModelOverride } from "openclaw/plugin-sdk/command-auth-native";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
import {
getSessionEntry,
readAmbientTranscriptWatermark,
resolveAmbientTranscriptWatermarkKey,
} from "openclaw/plugin-sdk/session-store-runtime";
import { resolveDefaultModelForAgent } from "./bot-handlers.agent.runtime.js";
import type { TelegramAmbientTranscriptWatermark } from "./bot-message-context.types.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import { resolveTelegramForumThreadId, shouldUseTelegramDmThreadSession } from "./bot/helpers.js";
import {
resolveTelegramConversationBaseSessionKey,
resolveTelegramConversationRoute,
} from "./conversation-route.js";
export function createTelegramMessageSessionRuntime({
accountId,
resolveTelegramGroupConfig,
telegramDeps,
}: Pick<
RegisterTelegramHandlerParams,
"accountId" | "resolveTelegramGroupConfig" | "telegramDeps"
>) {
const loadSessionEntry = telegramDeps.getSessionEntry ?? getSessionEntry;
const resolveTelegramSessionState = (params: {
chatId: number | string;
isGroup: boolean;
isForum: boolean;
messageThreadId?: number;
resolvedThreadId?: number;
botHasTopicsEnabled?: boolean;
senderId?: string | number;
runtimeCfg: OpenClawConfig;
}) => {
const resolvedThreadId =
params.resolvedThreadId ??
resolveTelegramForumThreadId({
isForum: params.isForum,
messageThreadId: params.messageThreadId,
});
const dmThreadId = !params.isGroup ? params.messageThreadId : undefined;
const topicThreadId = resolvedThreadId ?? dmThreadId;
const { topicConfig } = resolveTelegramGroupConfig(
params.chatId,
topicThreadId,
params.runtimeCfg,
);
const { route } = resolveTelegramConversationRoute({
cfg: params.runtimeCfg,
accountId,
chatId: params.chatId,
isGroup: params.isGroup,
resolvedThreadId,
replyThreadId: topicThreadId,
senderId: params.senderId,
topicAgentId: topicConfig?.agentId,
});
const baseSessionKey = resolveTelegramConversationBaseSessionKey({
cfg: params.runtimeCfg,
route,
chatId: params.chatId,
isGroup: params.isGroup,
senderId: params.senderId,
});
const threadKeys =
shouldUseTelegramDmThreadSession({
dmThreadId,
botHasTopicsEnabled: params.botHasTopicsEnabled,
}) && dmThreadId != null
? resolveThreadSessionKeys({
baseSessionKey,
threadId: `${params.chatId}:${dmThreadId}`,
})
: null;
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
const storePath = telegramDeps.resolveStorePath(params.runtimeCfg.session?.store, {
agentId: route.agentId,
});
const entry = loadSessionEntry({ storePath, sessionKey });
const storedOverride = resolveStoredModelOverride({
sessionEntry: entry,
loadSessionEntry: (parentSessionKey) =>
loadSessionEntry({ storePath, sessionKey: parentSessionKey }),
sessionKey,
defaultProvider: resolveDefaultModelForAgent({
cfg: params.runtimeCfg,
agentId: route.agentId,
}).provider,
});
if (storedOverride) {
return {
agentId: route.agentId,
sessionEntry: entry,
sessionKey,
storePath,
model: storedOverride.provider
? `${storedOverride.provider}/${storedOverride.model}`
: storedOverride.model,
};
}
const provider = entry?.modelProvider?.trim();
const model = entry?.model?.trim();
if (provider && model) {
return {
agentId: route.agentId,
sessionEntry: entry,
sessionKey,
storePath,
model: `${provider}/${model}`,
};
}
const modelCfg = params.runtimeCfg.agents?.defaults?.model;
return {
agentId: route.agentId,
sessionEntry: entry,
sessionKey,
storePath,
model: typeof modelCfg === "string" ? modelCfg : modelCfg?.primary,
};
};
const resolvePromptContextAmbientWatermark = (params: {
chatId: number | string;
isGroup: boolean;
resolvedThreadId?: number;
sessionKey: string;
storePath: string;
}): TelegramAmbientTranscriptWatermark | undefined => {
if (!params.isGroup) {
return undefined;
}
const key = (
telegramDeps.resolveAmbientTranscriptWatermarkKey ?? resolveAmbientTranscriptWatermarkKey
)({
channel: "telegram",
accountId,
conversationId: String(params.chatId),
...(params.resolvedThreadId !== undefined ? { threadId: params.resolvedThreadId } : {}),
});
return (telegramDeps.readAmbientTranscriptWatermark ?? readAmbientTranscriptWatermark)({
storePath: params.storePath,
sessionKey: params.sessionKey,
key,
});
};
return { resolveTelegramSessionState, resolvePromptContextAmbientWatermark };
}
@@ -1,73 +0,0 @@
// Telegram group-to-supergroup config migration handler.
import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-helpers";
import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation";
import { danger, warn } from "openclaw/plugin-sdk/runtime-env";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import { migrateTelegramGroupConfig } from "./group-migration.js";
export function registerTelegramMigrationHandler({
cfg,
accountId,
bot,
runtime,
telegramDeps,
shouldSkipUpdate,
}: RegisterTelegramHandlerParams) {
// Handle group migration to supergroup (chat ID changes)
bot.on("message:migrate_to_chat_id", async (ctx) => {
try {
const msg = ctx.message;
if (!msg?.migrate_to_chat_id) {
return;
}
if (shouldSkipUpdate(ctx)) {
return;
}
const oldChatId = String(msg.chat.id);
const newChatId = String(msg.migrate_to_chat_id);
const chatTitle = msg.chat.title ?? "Unknown";
runtime.log?.(warn(`[telegram] Group migrated: "${chatTitle}" ${oldChatId}${newChatId}`));
if (!resolveChannelConfigWrites({ cfg, channelId: "telegram", accountId })) {
runtime.log?.(warn("[telegram] Config writes disabled; skipping group config migration."));
return;
}
// Check if old chat ID has config and migrate it
const currentConfig = telegramDeps.getRuntimeConfig();
const migration = migrateTelegramGroupConfig({
cfg: currentConfig,
accountId,
oldChatId,
newChatId,
});
if (migration.migrated) {
runtime.log?.(warn(`[telegram] Migrating group config from ${oldChatId} to ${newChatId}`));
migrateTelegramGroupConfig({ cfg, accountId, oldChatId, newChatId });
await mutateConfigFile({
afterWrite: { mode: "auto" },
mutate: (draft) => {
migrateTelegramGroupConfig({ cfg: draft, accountId, oldChatId, newChatId });
},
});
runtime.log?.(warn(`[telegram] Group config migrated and saved successfully`));
} else if (migration.skippedExisting) {
runtime.log?.(
warn(
`[telegram] Group config already exists for ${newChatId}; leaving ${oldChatId} unchanged`,
),
);
} else {
runtime.log?.(
warn(`[telegram] No config found for old group ID ${oldChatId}, migration logged only`),
);
}
} catch (err) {
runtime.error?.(danger(`[telegram] Group migration handler failed: ${String(err)}`));
throw err;
}
});
}
@@ -1,171 +0,0 @@
// Telegram public-poll answer handler registration.
import type { ChatMember } from "grammy/types";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { TelegramHandlerAuthorizationRuntime } from "./bot-handlers.authorization.runtime.js";
import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import {
isTelegramSpooledReplayUpdate,
recordTelegramMessageProcessingResult,
} from "./bot-processing-outcome.js";
import { getPreparedTelegramPollAnswer } from "./poll-answer-context.js";
import { findTelegramPollRegistryEntry, retireTelegramPollRegistryEntry } from "./poll-registry.js";
function isCurrentTelegramChatMember(member: ChatMember): boolean {
return (
member.status === "creator" ||
member.status === "administrator" ||
member.status === "member" ||
(member.status === "restricted" && member.is_member)
);
}
export function registerTelegramPollHandlers(
{ accountId, bot, runtime, telegramDeps, shouldSkipUpdate }: RegisterTelegramHandlerParams,
messageRuntime: TelegramHandlerMessageRuntime,
authorizationRuntime: TelegramHandlerAuthorizationRuntime,
) {
const { resolveTelegramEventAuthorizationContext, authorizeTelegramEventSender } =
authorizationRuntime;
const { buildSyntheticTextMessage, buildSyntheticContext, processMessageWithReplyChain } =
messageRuntime;
bot.on("poll", async (ctx) => {
try {
const poll = ctx.poll;
if (!poll?.is_closed || shouldSkipUpdate(ctx)) {
return;
}
await retireTelegramPollRegistryEntry({ accountId, pollId: poll.id });
} catch (err) {
runtime.error?.(danger(`telegram poll handler failed: ${String(err)}`));
if (isTelegramSpooledReplayUpdate(ctx.update)) {
recordTelegramMessageProcessingResult({ kind: "failed-retryable", error: err });
return;
}
throw err;
}
});
// Telegram emits poll_answer only for non-anonymous polls, and the update omits
// chat/thread data. The send path records that origin in the keyed plugin store.
bot.on("poll_answer", async (ctx) => {
try {
const pollAnswer = ctx.pollAnswer;
if (!pollAnswer || shouldSkipUpdate(ctx)) {
return;
}
const optionIds = pollAnswer.option_ids ?? [];
const user = pollAnswer.user;
// Retractions have no selection to route. Bot voters and voter_chat-only
// answers have no user identity that can pass the sender authorization gate.
if (optionIds.length === 0 || !user || user.is_bot) {
return;
}
// A true miss is a safe no-op. Store failures throw so durable ingress can
// release the claim and replay instead of permanently dropping the vote.
const pollId = pollAnswer.poll_id;
const prepared = getPreparedTelegramPollAnswer(ctx.update);
const entry = prepared
? prepared.entry
: await findTelegramPollRegistryEntry({ pollId, accountId });
if (!entry) {
logVerbose(`telegram: poll_answer for poll ${pollId} has no registry entry; skipping`);
return;
}
const chatId = entry.chat.id;
const isGroup = entry.chat.type === "group" || entry.chat.type === "supergroup";
const senderId = user?.id != null ? String(user.id) : "";
const senderUsername = user?.username ?? "";
if (!isGroup && user.id !== chatId) {
logVerbose(`Blocked forwarded telegram poll_answer for DM ${chatId} from ${senderId}`);
return;
}
if (isGroup && !isCurrentTelegramChatMember(await bot.api.getChatMember(chatId, user.id))) {
logVerbose(
`Blocked forwarded telegram poll_answer for group ${chatId} from non-member ${senderId}`,
);
return;
}
const authorizationCfg = telegramDeps.getRuntimeConfig();
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
cfg: authorizationCfg,
chatId,
isGroup,
senderId,
threadSpec: entry.threadSpec,
});
const senderAuthorization = await authorizeTelegramEventSender({
chatId,
chatTitle: "title" in entry.chat ? entry.chat.title : undefined,
isGroup,
senderId,
senderUsername,
// Poll votes and reactions are both user-originated updates attached to
// bot-created UI, so they share the reaction authorization boundary.
mode: "reaction",
context: eventAuthContext,
});
if (!senderAuthorization) {
return;
}
// poll_answer has no thread id. A DM poll without persisted topic context
// cannot satisfy requireTopic and must not wake the base DM session.
if (!isGroup) {
const requireTopic = (
eventAuthContext.groupConfig as { requireTopic?: boolean } | undefined
)?.requireTopic;
if (requireTopic === true && eventAuthContext.dmThreadId == null) {
logVerbose(
`Blocked telegram poll_answer in DM ${chatId}: requireTopic=true but topic unknown`,
);
return;
}
}
const optionLabels = optionIds.map((index) => entry.options[index] ?? `option ${index}`);
const text = `Poll response to "${entry.question}": ${optionLabels.join(", ")}`;
const messageThreadId = "id" in entry.threadSpec ? entry.threadSpec.id : undefined;
const syntheticMessage = buildSyntheticTextMessage({
base: {
message_id: entry.messageId,
date: Math.floor(Date.now() / 1000),
chat: entry.chat,
...(messageThreadId == null
? {}
: {
message_thread_id: messageThreadId,
is_topic_message: true,
}),
},
from: user,
text,
});
const result = await processMessageWithReplyChain({
ctx: buildSyntheticContext(ctx, syntheticMessage),
msg: syntheticMessage,
allMedia: [],
storeAllowFrom: eventAuthContext.storeAllowFrom,
options: {
forceWasMentioned: true,
messageIdOverride:
typeof ctx.update.update_id === "number"
? String(ctx.update.update_id)
: `poll:${pollId}:${user.id}:${optionIds.join("-")}`,
},
});
recordTelegramMessageProcessingResult(result);
logVerbose(`telegram: poll_answer dispatched for poll ${pollId} by ${senderId}`);
} catch (err) {
runtime.error?.(danger(`telegram poll_answer handler failed: ${String(err)}`));
if (isTelegramSpooledReplayUpdate(ctx.update)) {
recordTelegramMessageProcessingResult({ kind: "failed-retryable", error: err });
return;
}
throw err;
}
});
}
@@ -3,9 +3,10 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { defaultTelegramBotDeps } from "./bot-deps.js";
import { createTelegramHandlerAuthorizationRuntime } from "./bot-handlers.authorization.runtime.js";
import { registerTelegramReactionHandler } from "./bot-handlers.reaction.runtime.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import { createTelegramEventBindings } from "./bot-handlers.event-bindings.js";
import { createTelegramHandlerAuthorization } from "./bot-handlers.inbound-authorization.js";
import { createTelegramMessagePipeline } from "./bot-handlers.message-pipeline.js";
import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js";
import type { TelegramThreadSpec } from "./bot/helpers.js";
const FIRE_EMOJI = "\u{1F525}";
@@ -106,11 +107,15 @@ function registerHandler(cfg: OpenClawConfig): ReactionHandler {
},
};
registerTelegramReactionHandler(
createTelegramEventBindings({
params,
{ resolveCachedMessageThreadSpec },
createTelegramHandlerAuthorizationRuntime(params),
);
message: {
...createTelegramMessagePipeline(params),
resolveCachedMessageThreadSpec,
},
authorization: createTelegramHandlerAuthorization(params),
registerMessages: () => {},
}).registerReaction();
const handler = handlers.get("message_reaction");
if (!handler) {
throw new Error("expected message_reaction handler");
@@ -1,216 +0,0 @@
// Telegram reaction handler registration.
import type { ReactionTypeEmoji } from "grammy/types";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
import { resolveTelegramAccount } from "./accounts.js";
import type { TelegramHandlerAuthorizationRuntime } from "./bot-handlers.authorization.runtime.js";
import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import {
buildTelegramGroupPeerId,
buildTelegramParentPeer,
resolveTelegramThreadSpec,
type TelegramThreadSpec,
} from "./bot/helpers.js";
import { resolveTelegramConversationRoute } from "./conversation-route.js";
/** Stable operator-facing reason for a scoped reaction dropped without a known topic. */
const TELEGRAM_REACTION_THREAD_UNRESOLVED_REASON = "thread-context-unavailable";
/** Only the message-cache lookup this handler needs, so tests can supply it directly. */
type TelegramReactionThreadRecovery = Pick<
TelegramHandlerMessageRuntime,
"resolveCachedMessageThreadSpec"
>;
export function registerTelegramReactionHandler(
{ accountId, bot, runtime, telegramDeps, shouldSkipUpdate }: RegisterTelegramHandlerParams,
threadRecovery: TelegramReactionThreadRecovery,
authorizationRuntime: TelegramHandlerAuthorizationRuntime,
) {
const { resolveTelegramEventAuthorizationContext, authorizeTelegramEventSender } =
authorizationRuntime;
// Handle emoji reactions to messages.
bot.on("message_reaction", async (ctx) => {
try {
const reaction = ctx.messageReaction;
if (!reaction) {
return;
}
if (shouldSkipUpdate(ctx)) {
return;
}
const chatId = reaction.chat.id;
const messageId = reaction.message_id;
const user = reaction.user;
const senderId = user?.id != null ? String(user.id) : "";
const senderUsername = user?.username ?? "";
const isGroup = reaction.chat.type === "group" || reaction.chat.type === "supergroup";
const isDirectMessagesChat = reaction.chat.is_direct_messages === true;
const isForum = !isDirectMessagesChat && reaction.chat.is_forum === true;
const authorizationCfg = telegramDeps.getRuntimeConfig();
const authorizationTelegramCfg = resolveTelegramAccount({
cfg: authorizationCfg,
accountId,
}).config;
// Resolve reaction notification mode (default: "own").
const reactionMode = authorizationTelegramCfg.reactionNotifications ?? "own";
if (reactionMode === "off") {
return;
}
if (user?.is_bot) {
return;
}
if (
reactionMode === "own" &&
!telegramDeps.wasSentByBot(chatId, messageId, authorizationCfg)
) {
logVerbose(
`telegram: skipped reaction on msg ${messageId} in chat ${chatId} (own mode, not sent by bot)`,
);
return;
}
// Detect added reactions. This runs before topic recovery so a reaction that
// enqueues nothing never spends a cache lookup or logs an unresolved-topic warning.
const oldEmojis = new Set(
reaction.old_reaction
.filter((r): r is ReactionTypeEmoji => r.type === "emoji")
.map((r) => r.emoji),
);
const addedReactions = reaction.new_reaction
.filter((r): r is ReactionTypeEmoji => r.type === "emoji")
.filter((r) => !oldEmojis.has(r.emoji));
if (addedReactions.length === 0) {
return;
}
// `MessageReactionUpdated` omits every topic field. Scoped reactions only have a
// route if the reacted-to message is still in the bounded message cache.
let recoveredThreadSpec: TelegramThreadSpec | undefined;
const requiredScope = isDirectMessagesChat
? "direct-messages"
: isForum
? "forum"
: undefined;
if (requiredScope) {
recoveredThreadSpec = await threadRecovery.resolveCachedMessageThreadSpec({
chatId,
messageId,
});
if (recoveredThreadSpec?.scope !== requiredScope || recoveredThreadSpec.id === undefined) {
runtime.log?.(
warn(
`telegram: skipped scoped reaction account=${accountId} chat=${chatId} message=${messageId} reason=${TELEGRAM_REACTION_THREAD_UNRESOLVED_REASON}`,
),
);
return;
}
}
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
cfg: authorizationCfg,
chatId,
isGroup,
senderId,
threadSpec:
recoveredThreadSpec ??
resolveTelegramThreadSpec({
isGroup,
isForum,
}),
});
const senderAuthorization = await authorizeTelegramEventSender({
chatId,
chatTitle: reaction.chat.title,
isGroup,
senderId,
senderUsername,
mode: "reaction",
context: eventAuthContext,
});
if (!senderAuthorization) {
return;
}
// Enforce requireTopic for DM reactions: since Telegram doesn't provide messageThreadId
// for reactions, we cannot determine if the reaction came from a topic, so block all
// reactions if requireTopic is enabled for this DM.
if (!isGroup) {
const requireTopic = (
eventAuthContext.groupConfig as { requireTopic?: boolean } | undefined
)?.requireTopic;
if (requireTopic === true) {
logVerbose(
`Blocked telegram reaction in DM ${chatId}: requireTopic=true but topic unknown for reactions`,
);
return;
}
}
const resolvedThreadId = eventAuthContext.resolvedThreadId;
let sessionKey: string;
if (recoveredThreadSpec) {
// Scoped topics carry topic agents and conversation bindings, so the recovered
// spec goes through the canonical route resolver instead of a bare peer route.
sessionKey = resolveTelegramConversationRoute({
cfg: eventAuthContext.cfg,
accountId,
chatId,
isGroup,
resolvedThreadId,
replyThreadId: recoveredThreadSpec.id,
senderId,
topicAgentId: eventAuthContext.topicConfig?.agentId,
}).route.sessionKey;
} else {
// Direct chats and non-forum groups have no topic to recover; keep their
// established peer route so reaction sessions stay where they already are.
const peerId = isGroup
? buildTelegramGroupPeerId(chatId, resolvedThreadId)
: String(chatId);
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
// Fresh config for bindings lookup; other routing inputs are payload-derived.
sessionKey = resolveAgentRoute({
cfg: eventAuthContext.cfg,
channel: "telegram",
accountId,
peer: { kind: isGroup ? "group" : "direct", id: peerId },
parentPeer,
}).sessionKey;
}
// Build sender label.
const senderName = user
? [user.first_name, user.last_name].filter(Boolean).join(" ").trim() || user.username
: undefined;
const senderUsernameLabel = user?.username ? `@${user.username}` : undefined;
let senderLabel = senderName;
if (senderName && senderUsernameLabel) {
senderLabel = `${senderName} (${senderUsernameLabel})`;
} else if (!senderName && senderUsernameLabel) {
senderLabel = senderUsernameLabel;
}
if (!senderLabel && user?.id) {
senderLabel = `id:${user.id}`;
}
senderLabel = senderLabel || "unknown";
// Enqueue system event for each added reaction.
for (const r of addedReactions) {
const emoji = r.emoji;
const text = `Telegram reaction added: ${emoji} by ${senderLabel} on msg ${messageId}`;
telegramDeps.enqueueSystemEvent(text, {
sessionKey,
contextKey: `telegram:reaction:add:${chatId}:${messageId}:${user?.id ?? "anon"}:${emoji}`,
});
logVerbose(`telegram: reaction event enqueued: ${text}`);
}
} catch (err) {
runtime.error?.(danger(`telegram reaction handler failed: ${String(err)}`));
throw err;
}
});
}
@@ -1,57 +1,45 @@
// Telegram tests cover bot handlers plugin behavior.
import { describe, expect, it } from "vitest";
import {
buildTelegramInboundDebounceConversationKey,
buildTelegramInboundDebounceKey,
} from "./bot-handlers.debounce-key.js";
// Telegram tests cover bot handler registration behavior.
import { Bot } from "grammy";
import { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
import { describe, expect, it, vi } from "vitest";
import { defaultTelegramBotDeps } from "./bot-deps.js";
import { registerTelegramHandlers } from "./bot-handlers.runtime.js";
import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js";
describe("buildTelegramInboundDebounceKey", () => {
it("uses the resolved account id instead of literal default when provided", () => {
expect(
buildTelegramInboundDebounceKey({
accountId: "work",
conversationKey: "12345",
senderId: "67890",
debounceLane: "default",
}),
).toBe("telegram:work:12345:67890:default");
});
describe("registerTelegramHandlers", () => {
it("registers middleware in transport order", () => {
const bot = new Bot("123456:handler-registration-test");
const on = vi.spyOn(bot, "on");
const params: RegisterTelegramHandlerParams = {
cfg: {},
accountId: "default",
bot,
mediaMaxBytes: 1,
opts: { token: "tok" },
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
telegramCfg: {},
telegramDeps: defaultTelegramBotDeps,
resolveGroupPolicy: () => ({ allowlistEnabled: false, allowed: true }),
resolveGroupActivation: () => undefined,
resolveGroupRequireMention: () => false,
resolveTelegramGroupConfig: () => ({}),
shouldSkipUpdate: () => false,
processMessage: vi.fn<RegisterTelegramHandlerParams["processMessage"]>(),
logger: getChildLogger({ module: "telegram/handler-registration-test" }),
};
it("falls back to literal default only when account id is actually absent", () => {
expect(
buildTelegramInboundDebounceKey({
accountId: undefined,
conversationKey: "12345",
senderId: "67890",
debounceLane: "forward",
}),
).toBe("telegram:default:12345:67890:forward");
});
registerTelegramHandlers(params);
it("keeps direct topic thread ids in the conversation key", () => {
const topic100 = buildTelegramInboundDebounceConversationKey({ chatId: 7, threadId: 100 });
const topic200 = buildTelegramInboundDebounceConversationKey({ chatId: 7, threadId: 200 });
expect(topic100).toBe("7:topic:100");
expect(topic200).toBe("7:topic:200");
expect(
buildTelegramInboundDebounceKey({
accountId: "default",
conversationKey: topic100,
senderId: "42",
debounceLane: "default",
}),
).not.toBe(
buildTelegramInboundDebounceKey({
accountId: "default",
conversationKey: topic200,
senderId: "42",
debounceLane: "default",
}),
);
});
it("uses the chat id as the conversation key when no thread is present", () => {
expect(buildTelegramInboundDebounceConversationKey({ chatId: 7 })).toBe("7");
expect(on.mock.calls.map(([trigger]) => trigger)).toEqual([
"message_reaction",
"poll",
"poll_answer",
"callback_query",
"message:migrate_to_chat_id",
"message",
"edited_message",
"channel_post",
"edited_channel_post",
]);
});
});
+27 -18
View File
@@ -1,22 +1,31 @@
// Telegram bot handler composition.
import { createTelegramHandlerAuthorizationRuntime } from "./bot-handlers.authorization.runtime.js";
import { registerTelegramCallbackQueryHandler } from "./bot-handlers.callback.runtime.js";
import { createTelegramHandlerInboundRuntime } from "./bot-handlers.inbound.runtime.js";
import { registerTelegramMessageHandlers } from "./bot-handlers.message-events.runtime.js";
import { createTelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js";
import { registerTelegramMigrationHandler } from "./bot-handlers.migration.runtime.js";
import { registerTelegramPollHandlers } from "./bot-handlers.poll-answer.runtime.js";
import { registerTelegramReactionHandler } from "./bot-handlers.reaction.runtime.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import { createTelegramCallbackRouter } from "./bot-handlers.callback-router.js";
import { createTelegramEventBindings } from "./bot-handlers.event-bindings.js";
import { createTelegramHandlerAuthorization } from "./bot-handlers.inbound-authorization.js";
import {
createTelegramInboundPipeline,
registerTelegramInboundHandlers,
} from "./bot-handlers.inbound-pipeline.js";
import { createTelegramMessagePipeline } from "./bot-handlers.message-pipeline.js";
import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js";
export const registerTelegramHandlers = (params: RegisterTelegramHandlerParams) => {
const messageRuntime = createTelegramHandlerMessageRuntime(params);
const authorizationRuntime = createTelegramHandlerAuthorizationRuntime(params);
const inboundRuntime = createTelegramHandlerInboundRuntime(params, messageRuntime);
const message = createTelegramMessagePipeline(params);
const authorization = createTelegramHandlerAuthorization(params);
const inboundPipeline = createTelegramInboundPipeline({ params, message, authorization });
const callbackRouter = createTelegramCallbackRouter({ params, message, authorization });
const eventBindings = createTelegramEventBindings({
params,
message,
authorization,
registerMessages: () =>
registerTelegramInboundHandlers({ bot: params.bot, pipeline: inboundPipeline }),
});
registerTelegramReactionHandler(params, messageRuntime, authorizationRuntime);
registerTelegramPollHandlers(params, messageRuntime, authorizationRuntime);
registerTelegramCallbackQueryHandler(params, messageRuntime, authorizationRuntime);
registerTelegramMigrationHandler(params);
registerTelegramMessageHandlers(params, messageRuntime, authorizationRuntime, inboundRuntime);
eventBindings.registerReaction();
eventBindings.registerPolls();
params.bot.on("callback_query", async (ctx) => {
await callbackRouter.route(ctx);
});
eventBindings.registerMigration();
eventBindings.registerMessages();
};
@@ -0,0 +1,124 @@
import type { Bot, Context } from "grammy";
import type {
ChannelGroupPolicy,
OpenClawConfig,
TelegramAccountConfig,
TelegramDirectConfig,
TelegramGroupConfig,
TelegramTopicConfig,
} from "openclaw/plugin-sdk/config-contracts";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { TelegramBotDeps } from "./bot-deps.js";
import type {
TelegramMediaRef,
TelegramMessageContextOptions,
TelegramPromptContextEntry,
} from "./bot-message-context.types.js";
import type {
TelegramMessageProcessingResult,
TelegramSpooledReplayDeferredParticipant,
} from "./bot-processing-outcome.js";
import type { TelegramUpdateKeyContext } from "./bot-updates.js";
import type { TelegramBotOptions } from "./bot.types.js";
import type { TelegramContext } from "./bot/types.js";
import type { TelegramTransport } from "./fetch.js";
import type { TelegramReplyChainEntry } from "./message-cache.js";
export type TelegramMessageProcessorTurnContext = {
cfg: OpenClawConfig;
telegramCfg: TelegramAccountConfig;
onDispatchStart?: () => Promise<void> | void;
spooledReplayAbortSignal?: AbortSignal;
spooledReplayParticipant?: TelegramSpooledReplayDeferredParticipant;
finalizeSpooledReplayResult?: (
result: TelegramMessageProcessingResult,
phase: "adopted" | "terminal",
) => Promise<TelegramMessageProcessingResult>;
completeSpooledReplayAfterIrrevocableAdoption?: (
error: unknown,
) => Promise<TelegramMessageProcessingResult> | TelegramMessageProcessingResult;
};
type ProcessTelegramMessageOptions = {
ctx: TelegramContext;
allMedia: TelegramMediaRef[];
storeAllowFrom: string[];
turnContext: TelegramMessageProcessorTurnContext;
options?: TelegramMessageContextOptions;
replyMedia?: TelegramMediaRef[];
replyChain?: TelegramReplyChainEntry[];
promptContext?: TelegramPromptContextEntry[];
};
type ProcessTelegramMessage = (
options: ProcessTelegramMessageOptions,
) => Promise<TelegramMessageProcessingResult>;
export type TelegramResolvedGroupConfig = {
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
topicConfig?: TelegramTopicConfig;
};
export type TelegramNativeCommandCallbackDispatcher = (params: {
botUser: Context["me"];
callbackQuery: NonNullable<Context["callbackQuery"]>;
commandText: string;
}) => Promise<{ handled: boolean; clearButtons: boolean }>;
type TelegramHandlerLogger = {
info: (fields: Record<string, unknown>, message: string) => void;
warn: (fields: Record<string, unknown>, message: string) => void;
};
export type RegisterTelegramHandlerParams = {
cfg: OpenClawConfig;
accountId: string;
bot: Bot;
mediaMaxBytes: number;
opts: TelegramBotOptions;
telegramTransport?: TelegramTransport;
runtime: RuntimeEnv;
telegramCfg: TelegramAccountConfig;
telegramDeps: TelegramBotDeps;
resolveGroupPolicy: (chatId: string | number, cfg: OpenClawConfig) => ChannelGroupPolicy;
resolveGroupActivation: (params: {
chatId: string | number;
agentId?: string;
messageThreadId?: number;
sessionKey?: string;
cfg: OpenClawConfig;
}) => boolean | undefined;
resolveGroupRequireMention: (chatId: string | number, cfg: OpenClawConfig) => boolean;
resolveTelegramGroupConfig: (
chatId: string | number,
messageThreadId: number | undefined,
cfg: OpenClawConfig,
) => TelegramResolvedGroupConfig;
shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean;
processMessage: ProcessTelegramMessage;
logger: TelegramHandlerLogger;
nativeCommandCallbackDispatcher?: TelegramNativeCommandCallbackDispatcher;
};
export type TelegramInboundDisposition =
| { kind: "ignored" }
| { kind: "recorded" }
| { kind: "buffered"; buffer: "text-fragment" | "media-group" | "debounce" }
| { kind: "processed" };
export interface TelegramInboundPipeline {
handle: (ctx: Context) => Promise<TelegramInboundDisposition>;
}
type TelegramCallbackRouteOutcome = { kind: "ignored" } | { kind: "handled" };
export interface TelegramCallbackRouter {
route(ctx: Context): Promise<TelegramCallbackRouteOutcome>;
}
export interface TelegramEventBindings {
registerReaction(): void;
registerPolls(): void;
registerMigration(): void;
registerMessages(): void;
}
@@ -9,7 +9,7 @@ import {
upsertSessionEntry,
} from "openclaw/plugin-sdk/session-store-runtime";
import { afterEach, describe, expect, it } from "vitest";
import { createTelegramMessageContextRuntime } from "./bot-handlers.message-context.runtime.js";
import { createTelegramMessageContextRuntime } from "./bot-handlers.message-context.js";
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
import type { TelegramPromptContextEntry } from "./bot-message-context.types.js";
+3 -2
View File
@@ -2,6 +2,7 @@
import { expectDefined } from "@openclaw/normalization-core";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { TelegramBotDeps } from "./bot-deps.js";
import type { TelegramMessageProcessorTurnContext } from "./bot-handlers.types.js";
import type { TelegramMessageProcessingResult } from "./bot-processing-outcome.js";
const buildTelegramMessageContext = vi.hoisted(() => vi.fn());
@@ -69,7 +70,7 @@ describe("telegram bot message processor", () => {
const baseTurnContext = {
cfg: {},
telegramCfg: {},
} satisfies import("./bot-message.js").TelegramMessageProcessorTurnContext;
} satisfies TelegramMessageProcessorTurnContext;
it("passes the effective per-DM history limit into message context", async () => {
buildTelegramMessageContext.mockResolvedValue(null);
@@ -122,7 +123,7 @@ describe("telegram bot message processor", () => {
async function processSampleMessage(
processMessage: ReturnType<typeof createTelegramMessageProcessor>,
turnContext?: Partial<import("./bot-message.js").TelegramMessageProcessorTurnContext>,
turnContext?: Partial<TelegramMessageProcessorTurnContext>,
primaryCtxOverrides: Record<string, unknown> = {},
options: Parameters<typeof processMessage>[4] = {},
allMedia: Parameters<typeof processMessage>[1] = [],
+1 -17
View File
@@ -11,6 +11,7 @@ import {
} from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { TelegramBotDeps } from "./bot-deps.js";
import type { TelegramMessageProcessorTurnContext } from "./bot-handlers.types.js";
import {
buildTelegramMessageContext,
type BuildTelegramMessageContextParams,
@@ -27,7 +28,6 @@ import {
isTelegramSpooledReplayUpdate,
recordTelegramMessageProcessingResult,
type TelegramMessageProcessingResult,
type TelegramSpooledReplayDeferredParticipant,
} from "./bot-processing-outcome.js";
import type { TelegramBotOptions } from "./bot.types.js";
import { buildTelegramThreadParams, resolveTelegramStreamMode } from "./bot/helpers.js";
@@ -70,22 +70,6 @@ type TelegramMessageProcessorDeps = Omit<
opts: Pick<TelegramBotOptions, "token" | "allowFrom" | "groupAllowFrom" | "replyToMode">;
};
export type TelegramMessageProcessorTurnContext = {
cfg: OpenClawConfig;
telegramCfg: TelegramAccountConfig;
onDispatchStart?: () => Promise<void> | void;
/** One-way cancellation from an outer spool owner into an isolated retry attempt. */
spooledReplayAbortSignal?: AbortSignal;
spooledReplayParticipant?: TelegramSpooledReplayDeferredParticipant;
finalizeSpooledReplayResult?: (
result: TelegramMessageProcessingResult,
phase: "adopted" | "terminal",
) => Promise<TelegramMessageProcessingResult>;
completeSpooledReplayAfterIrrevocableAdoption?: (
error: unknown,
) => Promise<TelegramMessageProcessingResult> | TelegramMessageProcessingResult;
};
export function resolveTelegramMessageTurnSettings(params: {
accountId: string;
senderId?: string | number;
@@ -14,6 +14,7 @@ import type { SessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
// Telegram tests cover bot native commands.session meta plugin behavior.
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js";
import type { TelegramNativeCommandDeps } from "./bot-native-command-deps.runtime.js";
import {
createTelegramGroupCommandContext,
@@ -22,7 +23,6 @@ import {
createTelegramTopicCommandContext,
type NativeCommandTestParams,
} from "./bot-native-commands.fixture-test-support.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import { runWithTelegramUpdateProcessingFrame } from "./bot-processing-outcome.js";
// All mocks scoped to this file only — does not affect bot-native-commands.test.ts
+6 -59
View File
@@ -49,7 +49,6 @@ import { codexChannelLoginRuntime } from "openclaw/plugin-sdk/provider-auth-logi
import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import {
formatSqliteSessionFileMarker,
@@ -65,12 +64,11 @@ import { resolveTelegramAccount } from "./accounts.js";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import { normalizeDmAllowFromWithStore, resolveTelegramEffectiveDmPolicy } from "./bot-access.js";
import type { TelegramBotDeps } from "./bot-deps.js";
import type { TelegramMediaRef } from "./bot-message-context.js";
import type { TelegramMessageContextOptions } from "./bot-message-context.types.js";
import {
resolveTelegramMessageTurnSettings,
type TelegramMessageProcessorTurnContext,
} from "./bot-message.js";
import type {
TelegramNativeCommandCallbackDispatcher,
TelegramResolvedGroupConfig,
} from "./bot-handlers.types.js";
import { resolveTelegramMessageTurnSettings } from "./bot-message.js";
import {
defaultTelegramNativeCommandDeps,
type TelegramNativeCommandDeps,
@@ -81,7 +79,6 @@ import {
syncTelegramMenuCommands as syncTelegramMenuCommandsRuntime,
type TelegramMenuCommand,
} from "./bot-native-command-menu.js";
import type { TelegramMessageProcessingResult } from "./bot-processing-outcome.js";
import type { TelegramUpdateKeyContext } from "./bot-updates.js";
import type { TelegramBotOptions } from "./bot.types.js";
import {
@@ -99,7 +96,7 @@ import {
resolveTelegramThreadSpec,
shouldUseTelegramDmThreadSession,
} from "./bot/helpers.js";
import type { TelegramContext, TelegramGetChat } from "./bot/types.js";
import type { TelegramGetChat } from "./bot/types.js";
import type { TelegramInlineButtons } from "./button-types.js";
import {
normalizeTelegramCommandName,
@@ -111,7 +108,6 @@ import {
resolveTelegramConversationRoute,
} from "./conversation-route.js";
import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js";
import type { TelegramTransport } from "./fetch.js";
import {
evaluateTelegramGroupBaseAccess,
evaluateTelegramGroupPolicyAccess,
@@ -148,10 +144,6 @@ type TelegramNativeReplyChannelData = {
};
};
type FastModeState = ReturnType<typeof resolveFastModeState>;
type TelegramResolvedGroupConfig = {
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
topicConfig?: TelegramTopicConfig;
};
type TelegramCommandAuthResult = {
chatId: number;
@@ -612,51 +604,6 @@ async function resolveTelegramNativeCommandThreadContext(params: {
};
}
export type RegisterTelegramHandlerParams = {
cfg: OpenClawConfig;
accountId: string;
bot: Bot;
mediaMaxBytes: number;
opts: TelegramBotOptions;
telegramTransport?: TelegramTransport;
runtime: RuntimeEnv;
telegramCfg: TelegramAccountConfig;
telegramDeps: TelegramBotDeps;
resolveGroupPolicy: (chatId: string | number, cfg: OpenClawConfig) => ChannelGroupPolicy;
resolveGroupActivation: (params: {
chatId: string | number;
agentId?: string;
messageThreadId?: number;
sessionKey?: string;
cfg: OpenClawConfig;
}) => boolean | undefined;
resolveGroupRequireMention: (chatId: string | number, cfg: OpenClawConfig) => boolean;
resolveTelegramGroupConfig: (
chatId: string | number,
messageThreadId: number | undefined,
cfg: OpenClawConfig,
) => TelegramResolvedGroupConfig;
shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean;
processMessage: (
ctx: TelegramContext,
allMedia: TelegramMediaRef[],
storeAllowFrom: string[],
turnContext: TelegramMessageProcessorTurnContext,
options?: TelegramMessageContextOptions,
replyMedia?: TelegramMediaRef[],
replyChain?: import("./message-cache.js").TelegramReplyChainEntry[],
promptContext?: import("./bot-message-context.types.js").TelegramPromptContextEntry[],
) => Promise<TelegramMessageProcessingResult>;
logger: ReturnType<typeof getChildLogger>;
nativeCommandCallbackDispatcher?: TelegramNativeCommandCallbackDispatcher;
};
type TelegramNativeCommandCallbackDispatcher = (params: {
botUser: Context["me"];
callbackQuery: NonNullable<Context["callbackQuery"]>;
commandText: string;
}) => Promise<{ handled: boolean; clearButtons: boolean }>;
function resolveTelegramNativeCommandDisableBlockStreaming(
telegramCfg: TelegramAccountConfig,
): boolean | undefined {
@@ -2,8 +2,19 @@
import { createServer, type Server } from "node:http";
import type { AddressInfo, Socket } from "node:net";
import { Bot } from "grammy";
import { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { registerTelegramMessageHandlers } from "./bot-handlers.message-events.runtime.js";
import { defaultTelegramBotDeps } from "./bot-deps.js";
import { createTelegramHandlerAuthorization } from "./bot-handlers.inbound-authorization.js";
import {
createTelegramInboundPipeline,
registerTelegramInboundHandlers,
} from "./bot-handlers.inbound-pipeline.js";
import {
createTelegramMessagePipeline,
type TelegramMessagePipeline,
} from "./bot-handlers.message-pipeline.js";
import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js";
import { telegramBotInfoForTest } from "./bot.create-telegram-bot.test-support.js";
import { resetTelegramForumFlagCacheForTest } from "./bot/helpers.js";
import { asTelegramClientFetch, createTelegramClientFetch } from "./client-fetch.js";
@@ -72,40 +83,60 @@ describe("Telegram supergroup ingress with a stalled Bot API response body", ()
botInfo,
client: { apiRoot, fetch: asTelegramClientFetch(clientFetch) },
});
const dispatched = vi.fn<
Parameters<typeof registerTelegramMessageHandlers>[3]["processInboundMessage"]
>(async () => undefined);
const dispatched = vi.fn<TelegramMessagePipeline["processMessageWithReplyChain"]>(async () => ({
kind: "completed",
}));
const emptyAllow = {
entries: [],
hasWildcard: false,
hasEntries: false,
invalidEntries: [],
};
const params: RegisterTelegramHandlerParams = {
accountId: "default",
bot,
cfg: {},
mediaMaxBytes: 1,
opts: { token: "123456:integration-token", botInfo },
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
telegramCfg: {},
telegramDeps: defaultTelegramBotDeps,
logger: getChildLogger({ module: "telegram/forum-ingress-test" }),
resolveGroupPolicy: () => ({ allowlistEnabled: false, allowed: true }),
resolveGroupActivation: () => undefined,
resolveGroupRequireMention: () => false,
resolveTelegramGroupConfig: () => ({}),
shouldSkipUpdate: () => false,
processMessage: async () => ({ kind: "completed" }),
};
const authorizeInboundMessage = vi.fn<
ReturnType<typeof createTelegramHandlerAuthorization>["authorizeInboundMessage"]
>(async (inbound) => ({
allowed: true as const,
effectiveDmAllow: emptyAllow,
context: {
cfg: {},
telegramCfg: {},
allowFrom: [],
dmPolicy: "open" as const,
threadSpec: inbound.isGroup ? ({ scope: "none" } as const) : ({ scope: "dm" } as const),
storeAllowFrom: [],
effectiveGroupAllow: emptyAllow,
hasGroupAllowOverride: false,
},
}));
const authorization = {
authorizeInboundMessage: vi.fn<
Parameters<typeof registerTelegramMessageHandlers>[2]["authorizeInboundMessage"]
>(async (params) => ({
allowed: true as const,
effectiveDmAllow: emptyAllow,
context: {
cfg: {},
telegramCfg: {},
allowFrom: [],
dmPolicy: "open" as const,
threadSpec: params.isGroup ? ({ scope: "none" } as const) : ({ scope: "dm" } as const),
storeAllowFrom: [],
effectiveGroupAllow: emptyAllow,
hasGroupAllowOverride: false,
},
})),
} satisfies Parameters<typeof registerTelegramMessageHandlers>[2];
const messageRuntime = {
...createTelegramHandlerAuthorization(params),
authorizeInboundMessage,
};
const message: TelegramMessagePipeline = {
...createTelegramMessagePipeline(params),
normalizePromptContextMinTimestampMs: () => undefined,
promptContextBoundaryOptions: () => ({}),
releaseDispatchDedupeClaims: () => undefined,
claimMessageDispatchDedupe: async () => ({ process: true, claims: [] }),
buildSyntheticContext: (context, message) => ({
message,
buildSyntheticContext: (context, syntheticMessage) => ({
message: syntheticMessage,
me: context.me,
getFile: context.getFile.bind(context),
}),
@@ -117,20 +148,15 @@ describe("Telegram supergroup ingress with a stalled Bot API response body", ()
model: undefined,
}),
resolvePromptContextAmbientWatermark: () => undefined,
recordMessageForReplyChain: async () => undefined,
} satisfies Parameters<typeof registerTelegramMessageHandlers>[1];
registerTelegramMessageHandlers(
{
bot,
opts: { botInfo },
runtime: { error: vi.fn() },
shouldSkipUpdate: () => false,
} satisfies Parameters<typeof registerTelegramMessageHandlers>[0],
messageRuntime,
authorization,
{ processInboundMessage: dispatched },
);
recordMessageForReplyChain: async (msg) => ({
messageId: String(msg.message_id),
sender: "integration sender",
sourceMessage: msg,
}),
processMessageWithReplyChain: dispatched,
};
const pipeline = createTelegramInboundPipeline({ params, message, authorization });
registerTelegramInboundHandlers({ bot, pipeline });
const headersReceived = new Promise<void>((resolve) => {
resolveGetChatHeaders = resolve;
@@ -159,7 +185,12 @@ describe("Telegram supergroup ingress with a stalled Bot API response body", ()
},
});
expect(dispatched).toHaveBeenCalledTimes(1);
expect(dispatched.mock.calls[0]?.[0]).toMatchObject({ chatId: 222, isGroup: false });
expect(dispatched.mock.calls[0]?.[0].msg.chat.id).toBe(222);
expect(authorizeInboundMessage.mock.calls[0]?.[0]).toMatchObject({
chatId: 222,
isGroup: false,
isForum: false,
});
const groupResult = await Promise.race([
groupDelivery.then(() => "dispatched" as const),
@@ -170,7 +201,8 @@ describe("Telegram supergroup ingress with a stalled Bot API response body", ()
expect(groupResult).toBe("dispatched");
expect(dispatched).toHaveBeenCalledTimes(2);
expect(dispatched.mock.calls[1]?.[0]).toMatchObject({
expect(dispatched.mock.calls[1]?.[0].msg.chat.id).toBe(-100364);
expect(authorizeInboundMessage.mock.calls[1]?.[0]).toMatchObject({
chatId: -100364,
isGroup: true,
isForum: false,
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { handleTelegramQuestionCallback } from "./bot-handlers.callback-questions.runtime.js";
import { handleTelegramQuestionCallback } from "./bot-handlers.callback-actions.js";
import { canonicalizeTelegramPresentationPayload } from "./interactive-fallback.js";
import { parseTelegramQuestionCallbackData } from "./question-callback-data.js";
@@ -8,7 +8,7 @@ import {
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTelegramCallbackMessageActions } from "./bot-handlers.callback-actions.runtime.js";
import { createTelegramCallbackMessageActions } from "./bot-handlers.callback-actions.js";
import { asTelegramClientFetch } from "./client-fetch.js";
import { createTelegramDraftStream } from "./draft-stream.js";
import { setTelegramRuntime } from "./runtime.js";