Files
openclaw/extensions/telegram/src/bot-message.ts
Vincent Koc f79e885985 fix(telegram): harden spooled turn adoption (#103695)
* fix(telegram): harden spooled turn adoption

* fix(telegram): retain fence through adoption

* fix(auto-reply): await queued admission hooks

* fix(telegram): clean up adoption integration

* fix(telegram): break spool retry import cycle

* fix(telegram): serialize spool timeout with adoption
2026-07-10 07:08:35 -07:00

497 lines
19 KiB
TypeScript

// Telegram plugin module implements bot message behavior.
import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking";
import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history";
import {
createSubsystemLogger,
danger,
logVerbose,
shouldLogVerbose,
sleepWithAbort,
} 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 {
createTelegramSpooledReplayParticipant,
createTelegramSpooledReplayDeferredParticipant,
getTelegramSpooledReplayDeferredParticipant,
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";
import type { TelegramContext } from "./bot/types.js";
import type { TelegramReplyChainEntry } from "./message-cache.js";
import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js";
import { TELEGRAM_RICH_TEXT_LIMIT } from "./rich-message.js";
import { resolveSpooledUpdatePersistenceRetryDelayMs } from "./spooled-update-retry-policy.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"
| "cfg"
| "historyLimit"
| "dmPolicy"
| "allowFrom"
| "groupAllowFrom"
| "ackReactionScope"
> & {
runtime: RuntimeEnv;
telegramDeps: TelegramBotDeps;
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;
cfg: OpenClawConfig;
telegramCfg: TelegramAccountConfig;
opts: Pick<TelegramBotOptions, "allowFrom" | "groupAllowFrom" | "replyToMode">;
}) {
const allowFrom = params.opts.allowFrom ?? params.telegramCfg.allowFrom;
const telegramTextLimit =
params.telegramCfg.richMessages === true ? TELEGRAM_RICH_TEXT_LIMIT : TELEGRAM_TEXT_CHUNK_LIMIT;
return {
ackReactionScope: params.cfg.messages?.ackReactionScope ?? "group-mentions",
allowFrom,
dmPolicy: params.telegramCfg.dmPolicy ?? "pairing",
groupAllowFrom:
params.opts.groupAllowFrom ??
params.telegramCfg.groupAllowFrom ??
params.telegramCfg.allowFrom ??
allowFrom,
historyLimit: Math.max(
0,
params.telegramCfg.historyLimit ??
params.cfg.messages?.groupChat?.historyLimit ??
DEFAULT_GROUP_HISTORY_LIMIT,
),
replyToMode: params.opts.replyToMode ?? params.telegramCfg.replyToMode ?? "off",
streamMode: resolveTelegramStreamMode(params.telegramCfg),
textLimit: Math.min(
resolveTextChunkLimit(params.cfg, "telegram", params.accountId, {
fallbackLimit: telegramTextLimit,
}),
telegramTextLimit,
),
};
}
export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDeps) => {
const {
bot,
account,
groupHistories,
logger,
resolveGroupActivation,
resolveGroupRequireMention,
resolveTelegramGroupConfig,
sendChatActionHandler,
runtime,
telegramDeps,
opts,
} = deps;
const sessionRuntime = {
...(telegramDeps.buildChannelInboundEventContext
? { buildChannelInboundEventContext: telegramDeps.buildChannelInboundEventContext }
: {}),
...(telegramDeps.readSessionUpdatedAt
? { readSessionUpdatedAt: telegramDeps.readSessionUpdatedAt }
: {}),
...(telegramDeps.readAmbientTranscriptWatermark
? { readAmbientTranscriptWatermark: telegramDeps.readAmbientTranscriptWatermark }
: {}),
...(telegramDeps.recordInboundSession
? { recordInboundSession: telegramDeps.recordInboundSession }
: {}),
...(telegramDeps.resolveAmbientTranscriptWatermarkKey
? { resolveAmbientTranscriptWatermarkKey: telegramDeps.resolveAmbientTranscriptWatermarkKey }
: {}),
...(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[],
turnContext: TelegramMessageProcessorTurnContext,
options?: TelegramMessageContextOptions,
replyMedia?: TelegramMediaRef[],
replyChain?: TelegramReplyChainEntry[],
promptContext?: TelegramPromptContextEntry[],
) => {
const turnCfg = turnContext.cfg;
const turnTelegramCfg = turnContext.telegramCfg;
const turnSettings = resolveTelegramMessageTurnSettings({
accountId: account.accountId,
cfg: turnCfg,
telegramCfg: turnTelegramCfg,
opts,
});
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: turnCfg,
account,
historyLimit: turnSettings.historyLimit,
groupHistories,
dmPolicy: turnSettings.dmPolicy,
allowFrom: turnSettings.allowFrom,
groupAllowFrom: turnSettings.groupAllowFrom,
ackReactionScope: turnSettings.ackReactionScope,
logger,
resolveGroupActivation,
resolveGroupRequireMention,
resolveTelegramGroupConfig,
sendChatActionHandler,
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,
}),
);
const spooledReplay =
options?.spooledReplay === true || isTelegramSpooledReplayUpdate(primaryCtx.update);
if (!spooledReplay) {
await turnContext.onDispatchStart?.();
}
const runDispatch = async (params: {
onTurnAdopted?: () => void | Promise<void>;
onTurnDeferred?: () => void;
onTurnAbandoned?: () => void;
turnAbortSignal?: AbortSignal;
}): Promise<TelegramMessageProcessingResult> => {
try {
const dispatchResult = await dispatchTelegramMessage({
context,
bot,
cfg: context.cfg,
runtime,
replyToMode: turnSettings.replyToMode,
streamMode: turnSettings.streamMode,
textLimit: turnSettings.textLimit,
telegramCfg: turnTelegramCfg,
telegramDeps,
opts,
retryDispatchErrors: spooledReplay,
suppressFailureFallback: spooledReplay,
onTurnAdopted: params.onTurnAdopted,
onTurnDeferred: params.onTurnDeferred,
onTurnAbandoned: params.onTurnAbandoned,
turnAbortSignal: params.turnAbortSignal,
});
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;
}
};
// Spooled ingress: complete the spool row at turn adoption (recovery state
// persisted), not settle. The deferred participant hands ownership back to
// the spool drain so the per-chat lane frees while the agent turn continues.
if (spooledReplay) {
const existingParticipant =
turnContext.spooledReplayParticipant ??
(options?.isolateSpooledReplaySettlement
? undefined
: getTelegramSpooledReplayDeferredParticipant());
const participant =
existingParticipant ??
(options?.isolateSpooledReplaySettlement
? undefined
: createTelegramSpooledReplayDeferredParticipant(
`agent-turn:${context.chatId}:${context.ctxPayload.MessageSid ?? Date.now()}`,
)) ??
createTelegramSpooledReplayParticipant(
`agent-turn:${context.chatId}:${context.ctxPayload.MessageSid ?? Date.now()}`,
);
let adopted = false;
let adoptionAttempted = false;
let adoptionFinalizationError: unknown;
let deferred = false;
let settledResult: TelegramMessageProcessingResult | undefined;
let settlement: Promise<TelegramMessageProcessingResult> | undefined;
const settle = async (
result: TelegramMessageProcessingResult,
phase: "adopted" | "terminal",
): Promise<TelegramMessageProcessingResult> => {
if (settledResult) {
return settledResult;
}
if (settlement) {
return await settlement;
}
settlement = (async () => {
let finalized: TelegramMessageProcessingResult;
try {
finalized = turnContext.finalizeSpooledReplayResult
? await turnContext.finalizeSpooledReplayResult(result, phase)
: result;
} catch (error) {
finalized = { kind: "failed-retryable", error };
}
// A deferred queue item still owns the turn when its admission
// callback fails. Leave the spool participant pending so the queue
// can retry admission without creating a second ingress owner.
if (phase === "adopted" && finalized.kind !== "completed") {
return finalized;
}
if (phase === "adopted" && finalized.kind === "completed") {
adopted = true;
}
settledResult = finalized;
participant.settle(finalized);
return finalized;
})();
try {
return await settlement;
} finally {
if (!settledResult) {
settlement = undefined;
}
}
};
const run = async () => {
const turnAbortSignal = turnContext.spooledReplayAbortSignal
? AbortSignal.any([participant.abortSignal, turnContext.spooledReplayAbortSignal])
: participant.abortSignal;
const result = await runDispatch({
turnAbortSignal,
onTurnAdopted: async () => {
if (adopted) {
return;
}
adoptionAttempted = true;
const adoptedResult = await settle({ kind: "completed" }, "adopted");
if (adoptedResult.kind !== "completed") {
adoptionFinalizationError =
adoptedResult.kind === "failed-retryable"
? adoptedResult.error
: new Error("telegram spooled turn adoption was not completed");
throw adoptedResult.kind === "failed-retryable"
? adoptedResult.error
: new Error("telegram spooled turn adoption was not completed");
}
},
onTurnDeferred: () => {
deferred = true;
},
onTurnAbandoned: () => {
if (!adopted) {
void settle({ kind: "skipped" }, "terminal");
}
},
});
if (adopted) {
return { kind: "completed" } satisfies TelegramMessageProcessingResult;
}
if (settledResult) {
return settledResult;
}
if (adoptionAttempted && !deferred && result.kind === "completed") {
runtime.error?.(
danger(
`telegram spooled turn adoption finalization failed after active steer commit: ${String(
adoptionFinalizationError,
)}`,
),
);
let retryError = adoptionFinalizationError;
let retryAttempt = 0;
while (!turnAbortSignal.aborted) {
retryAttempt += 1;
try {
const completed =
(await turnContext.completeSpooledReplayAfterIrrevocableAdoption?.(retryError)) ??
({ kind: "completed" } satisfies TelegramMessageProcessingResult);
if (completed.kind === "completed") {
adopted = true;
settledResult = completed;
participant.settle(completed);
return completed;
}
retryError =
completed.kind === "failed-retryable"
? completed.error
: new Error("telegram spooled turn adoption was not completed");
} catch (error) {
retryError = error;
}
const delayMs = resolveSpooledUpdatePersistenceRetryDelayMs(retryAttempt);
runtime.error?.(
danger(
`telegram spooled turn durable replay protection retry ${retryAttempt} failed after active steer commit; retrying in ${delayMs}ms: ${String(retryError)}`,
),
);
try {
await sleepWithAbort(delayMs, turnAbortSignal);
} catch {
break;
}
}
if (turnAbortSignal.aborted && !participant.abortSignal.aborted) {
const abortResult: TelegramMessageProcessingResult =
turnAbortSignal.reason === "skipped"
? { kind: "skipped" }
: {
kind: "failed-retryable",
error:
turnAbortSignal.reason ??
new Error("telegram spooled replay owner cancelled"),
};
participant.settle(abortResult);
}
return await participant.task;
}
if (deferred) {
return await participant.task;
}
return await settle(result, "terminal");
};
// The participant is the ingress ownership boundary. Direct and buffered
// callers both return when it is durably adopted or terminally rejected.
void run();
return await participant.task;
}
return await runDispatch({});
};
};