mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
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:
@@ -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;
|
||||
}
|
||||
+63
-1
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
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 <runtime> -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(() => {});
|
||||
}
|
||||
+295
-8
@@ -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 {
|
||||
+342
-24
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
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 <runtime> -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 };
|
||||
}
|
||||
+174
-23
@@ -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;
|
||||
}
|
||||
+211
-9
@@ -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,
|
||||
};
|
||||
}
|
||||
+17
-8
@@ -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");
|
||||
});
|
||||
});
|
||||
+98
-35
@@ -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);
|
||||
}
|
||||
+72
-66
@@ -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;
|
||||
|
||||
+247
-3
@@ -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,
|
||||
|
||||
+192
-38
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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] = [],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+72
-40
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user