Files
openclaw/extensions/telegram/src/bot-message.ts
Josh Avant 9921825e17 Fix Telegram spooled buffered replay (#92281)
* fix telegram spooled buffered replay

* fix telegram replay type checks

* fix telegram replay lint

* test telegram replay visible output retry guard

* fix telegram rollback failure retry
2026-06-12 11:51:46 -05:00

251 lines
8.4 KiB
TypeScript

// Telegram plugin module implements bot message behavior.
import type { ReplyToMode } from "openclaw/plugin-sdk/config-contracts";
import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts";
import {
createSubsystemLogger,
danger,
logVerbose,
shouldLogVerbose,
} from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { TelegramBotDeps } from "./bot-deps.js";
import {
buildTelegramMessageContext,
type BuildTelegramMessageContextParams,
type TelegramMediaRef,
} from "./bot-message-context.js";
import type { TelegramMessageContextOptions } from "./bot-message-context.types.js";
import type { TelegramPromptContextEntry } from "./bot-message-context.types.js";
import { dispatchTelegramMessage } from "./bot-message-dispatch.js";
import {
isTelegramSpooledReplayUpdate,
recordTelegramMessageProcessingResult,
type TelegramMessageProcessingResult,
} from "./bot-processing-outcome.js";
import type { TelegramBotOptions } from "./bot.types.js";
import { buildTelegramThreadParams } from "./bot/helpers.js";
import type { TelegramContext, TelegramStreamMode } from "./bot/types.js";
import type { TelegramReplyChainEntry } from "./message-cache.js";
const telegramInboundLog = createSubsystemLogger("gateway/channels/telegram").child("inbound");
export function formatTelegramInboundLogLine(params: {
from: string;
to: string;
chatType: string;
body: string;
mediaType?: string;
}): string {
const kindLabel = params.mediaType ? `, ${params.mediaType}` : "";
return `Inbound message ${params.from} -> ${params.to} (${params.chatType}${kindLabel}, ${params.body.length} chars)`;
}
type TelegramMessageProcessorDeps = Omit<
BuildTelegramMessageContextParams,
"primaryCtx" | "allMedia" | "storeAllowFrom" | "options"
> & {
telegramCfg: TelegramAccountConfig;
runtime: RuntimeEnv;
replyToMode: ReplyToMode;
streamMode: TelegramStreamMode;
textLimit: number;
telegramDeps: TelegramBotDeps;
opts: Pick<TelegramBotOptions, "token">;
};
export type TelegramMessageProcessorLifecycle = {
onDispatchStart?: () => Promise<void> | void;
};
export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDeps) => {
const {
bot,
cfg,
account,
telegramCfg,
historyLimit,
groupHistories,
dmPolicy,
allowFrom,
groupAllowFrom,
ackReactionScope,
logger,
resolveGroupActivation,
resolveGroupRequireMention,
resolveTelegramGroupConfig,
loadFreshConfig,
sendChatActionHandler,
runtime,
replyToMode,
streamMode,
textLimit,
telegramDeps,
opts,
} = deps;
const sessionRuntime = {
...(telegramDeps.buildChannelInboundEventContext
? { buildChannelInboundEventContext: telegramDeps.buildChannelInboundEventContext }
: {}),
...(telegramDeps.readSessionUpdatedAt
? { readSessionUpdatedAt: telegramDeps.readSessionUpdatedAt }
: {}),
...(telegramDeps.recordInboundSession
? { recordInboundSession: telegramDeps.recordInboundSession }
: {}),
...(telegramDeps.resolveInboundLastRouteSessionKey
? { resolveInboundLastRouteSessionKey: telegramDeps.resolveInboundLastRouteSessionKey }
: {}),
...(telegramDeps.resolvePinnedMainDmOwnerFromAllowlist
? {
resolvePinnedMainDmOwnerFromAllowlist: telegramDeps.resolvePinnedMainDmOwnerFromAllowlist,
}
: {}),
resolveStorePath: telegramDeps.resolveStorePath,
};
const contextRuntime = telegramDeps.recordChannelActivity
? { recordChannelActivity: telegramDeps.recordChannelActivity }
: undefined;
return async (
primaryCtx: TelegramContext,
allMedia: TelegramMediaRef[],
storeAllowFrom: string[],
options?: TelegramMessageContextOptions,
replyMedia?: TelegramMediaRef[],
replyChain?: TelegramReplyChainEntry[],
promptContext?: TelegramPromptContextEntry[],
lifecycle?: TelegramMessageProcessorLifecycle,
) => {
const ingressReceivedAtMs =
typeof options?.receivedAtMs === "number" && Number.isFinite(options.receivedAtMs)
? options.receivedAtMs
: undefined;
const ingressDebugEnabled =
shouldLogVerbose() || process.env.OPENCLAW_DEBUG_TELEGRAM_INGRESS === "1";
const ingressContextStartMs = ingressReceivedAtMs ? Date.now() : undefined;
const recordCurrentUpdateProcessingResult = (result: TelegramMessageProcessingResult) => {
if (options?.spooledReplay === true) {
return;
}
recordTelegramMessageProcessingResult(result);
};
const context = await buildTelegramMessageContext({
primaryCtx,
allMedia,
replyMedia,
replyChain,
promptContext,
storeAllowFrom,
options,
bot,
cfg,
account,
historyLimit,
groupHistories,
dmPolicy,
allowFrom,
groupAllowFrom,
ackReactionScope,
logger,
resolveGroupActivation,
resolveGroupRequireMention,
resolveTelegramGroupConfig,
sendChatActionHandler,
loadFreshConfig,
runtime: contextRuntime,
sessionRuntime,
upsertPairingRequest: telegramDeps.upsertChannelPairingRequest,
});
if (!context) {
if (ingressDebugEnabled && ingressReceivedAtMs && ingressContextStartMs) {
logVerbose(
`telegram ingress: chatId=${primaryCtx.message.chat.id} dropped after ${Date.now() - ingressReceivedAtMs}ms` +
(options?.ingressBuffer ? ` buffer=${options.ingressBuffer}` : ""),
);
}
const result: TelegramMessageProcessingResult = { kind: "skipped" };
recordCurrentUpdateProcessingResult(result);
return result;
}
if (ingressDebugEnabled && ingressReceivedAtMs && ingressContextStartMs) {
logVerbose(
`telegram ingress: chatId=${context.chatId} contextReadyMs=${Date.now() - ingressReceivedAtMs}` +
` preDispatchMs=${Date.now() - ingressContextStartMs}` +
(options?.ingressBuffer ? ` buffer=${options.ingressBuffer}` : ""),
);
}
if (
context.ctxPayload.InboundEventKind !== "room_event" &&
context.initialTypingCueSent !== true
) {
void context.sendTyping().catch((err: unknown) => {
logVerbose(`telegram early typing cue failed for chat ${context.chatId}: ${String(err)}`);
});
}
telegramInboundLog.info(
formatTelegramInboundLogLine({
from: context.ctxPayload.From,
to: context.primaryCtx.me?.username
? `@${context.primaryCtx.me.username}`
: context.ctxPayload.To,
chatType: context.ctxPayload.ChatType,
body: context.ctxPayload.RawBody,
mediaType: allMedia[0]?.contentType,
}),
);
await lifecycle?.onDispatchStart?.();
const spooledReplay =
options?.spooledReplay === true || isTelegramSpooledReplayUpdate(primaryCtx.update);
try {
const dispatchResult = await dispatchTelegramMessage({
context,
bot,
cfg,
runtime,
replyToMode,
streamMode,
textLimit,
telegramCfg,
telegramDeps,
opts,
retryDispatchErrors: spooledReplay,
suppressFailureFallback: spooledReplay,
});
if (dispatchResult?.kind === "failed-retryable") {
const result: TelegramMessageProcessingResult = {
kind: "failed-retryable",
error: dispatchResult.error,
};
recordCurrentUpdateProcessingResult(result);
return result;
}
if (ingressDebugEnabled && ingressReceivedAtMs) {
logVerbose(
`telegram ingress: chatId=${context.chatId} dispatchCompleteMs=${Date.now() - ingressReceivedAtMs}` +
(options?.ingressBuffer ? ` buffer=${options.ingressBuffer}` : ""),
);
}
const result: TelegramMessageProcessingResult = { kind: "completed" };
recordCurrentUpdateProcessingResult(result);
return result;
} catch (err) {
runtime.error?.(danger(`telegram message processing failed: ${String(err)}`));
if (!spooledReplay) {
try {
await bot.api.sendMessage(
context.chatId,
"Something went wrong while processing your request. Please try again.",
buildTelegramThreadParams(context.threadSpec),
);
} catch {}
}
const result: TelegramMessageProcessingResult = {
kind: "failed-retryable",
error: err,
};
recordCurrentUpdateProcessingResult(result);
return result;
}
};
};