mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-23 14:08:10 +00:00
* Keep command text in progress drafts * test(channels): align successful progress drafts --------- Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2775 lines
101 KiB
TypeScript
2775 lines
101 KiB
TypeScript
// Telegram plugin module implements bot message dispatch behavior.
|
|
import path from "node:path";
|
|
import type { Bot } from "grammy";
|
|
import {
|
|
appendSessionTranscriptMessage,
|
|
emitSessionTranscriptUpdate,
|
|
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
|
import {
|
|
DEFAULT_TIMING,
|
|
logAckFailure,
|
|
logTypingFailure,
|
|
removeAckReactionAfterReply,
|
|
} from "openclaw/plugin-sdk/channel-feedback";
|
|
import {
|
|
formatInboundEnvelope,
|
|
resolveEnvelopeFormatOptions,
|
|
runChannelInboundEvent,
|
|
} from "openclaw/plugin-sdk/channel-inbound";
|
|
import { CURRENT_MESSAGE_MARKER } from "openclaw/plugin-sdk/channel-mention-gating";
|
|
import {
|
|
createChannelMessageReplyPipeline,
|
|
createPreviewMessageReceipt,
|
|
createOutboundPayloadPlan,
|
|
deriveDurableFinalDeliveryRequirements,
|
|
projectOutboundPayloadPlanForDelivery,
|
|
} from "openclaw/plugin-sdk/channel-outbound";
|
|
import {
|
|
buildChannelProgressDraftLine,
|
|
buildChannelProgressDraftLineForEntry,
|
|
type ChannelProgressDraftLine,
|
|
type ChannelProgressDraftCompositorLine,
|
|
createChannelProgressDraftCompositor,
|
|
resolveChannelStreamingBlockEnabled,
|
|
resolveTranscriptBackedChannelFinalText,
|
|
} from "openclaw/plugin-sdk/channel-outbound";
|
|
import type {
|
|
OpenClawConfig,
|
|
ReplyToMode,
|
|
TelegramAccountConfig,
|
|
} from "openclaw/plugin-sdk/config-contracts";
|
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
import { normalizeMessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
|
|
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
|
import { createChannelHistoryWindow } from "openclaw/plugin-sdk/reply-history";
|
|
import {
|
|
isReplyPayloadNonTerminalToolErrorWarning,
|
|
resolveSendableOutboundReplyParts,
|
|
} from "openclaw/plugin-sdk/reply-payload";
|
|
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";
|
|
import type { BlockReplyContext } from "openclaw/plugin-sdk/reply-runtime";
|
|
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
|
import {
|
|
createSubsystemLogger,
|
|
danger,
|
|
logVerbose,
|
|
sleepWithAbort,
|
|
} from "openclaw/plugin-sdk/runtime-env";
|
|
import { resolveTelegramConfigReasoningDefault } from "./agent-config.js";
|
|
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
|
import type { TelegramBotDeps } from "./bot-deps.js";
|
|
import type { TelegramMessageContext } from "./bot-message-context.js";
|
|
import {
|
|
findModelInCatalog,
|
|
loadModelCatalog,
|
|
modelSupportsVision,
|
|
resolveAgentDir,
|
|
resolveDefaultModelForAgent,
|
|
} from "./bot-message-dispatch.agent.runtime.js";
|
|
import { deduplicateBlockSentMedia } from "./bot-message-dispatch.media-dedup.js";
|
|
import {
|
|
generateTopicLabel,
|
|
getAgentScopedMediaLocalRoots,
|
|
loadSessionStore,
|
|
readLatestAssistantTextFromSessionTranscript,
|
|
resolveAutoTopicLabelConfig,
|
|
resolveChunkMode,
|
|
resolveMarkdownTableMode,
|
|
resolveAndPersistSessionFile,
|
|
resolveSessionStoreEntry,
|
|
updateSessionStoreEntry,
|
|
} from "./bot-message-dispatch.runtime.js";
|
|
import type { TelegramBotOptions } from "./bot.types.js";
|
|
import { deliverReplies, emitInternalMessageSentHook } from "./bot/delivery.js";
|
|
import {
|
|
buildTelegramGroupPeerId,
|
|
buildTelegramGroupFrom,
|
|
buildTelegramInboundOriginTarget,
|
|
buildGroupLabel,
|
|
buildTypingThreadParams,
|
|
getTelegramTextParts,
|
|
resolveTelegramReplyId,
|
|
type TelegramThreadSpec,
|
|
} from "./bot/helpers.js";
|
|
import {
|
|
addTelegramNativeQuoteCandidate,
|
|
buildTelegramNativeQuoteCandidate,
|
|
type TelegramNativeQuoteCandidateByMessageId,
|
|
} from "./bot/native-quote.js";
|
|
import type { TelegramStreamMode } from "./bot/types.js";
|
|
import { resolveTelegramInlineButtons, type TelegramInlineButtons } from "./button-types.js";
|
|
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
|
|
import { createTelegramDraftStream, type TelegramDraftPreview } from "./draft-stream.js";
|
|
import {
|
|
buildTelegramErrorScopeKey,
|
|
isSilentErrorPolicy,
|
|
resolveTelegramErrorPolicy,
|
|
shouldSuppressTelegramError,
|
|
} from "./error-policy.js";
|
|
import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js";
|
|
import { renderTelegramHtmlText } from "./format.js";
|
|
import { includesRecentTelegramGroupHistoryContext } from "./group-history-context.js";
|
|
import { beginTelegramInboundEventDeliveryCorrelation } from "./inbound-event-delivery.js";
|
|
import {
|
|
createLaneDeliveryStateTracker,
|
|
createLaneTextDeliverer,
|
|
type DraftLaneState,
|
|
type LaneDeliveryResult,
|
|
type LaneName,
|
|
} from "./lane-delivery.js";
|
|
import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js";
|
|
import { recordOutboundMessageForPromptContext } from "./outbound-message-context.js";
|
|
import {
|
|
createTelegramReasoningStepState,
|
|
splitTelegramReasoningText,
|
|
} from "./reasoning-lane-coordinator.js";
|
|
import {
|
|
buildTelegramRichHtml,
|
|
buildTelegramRichMarkdown,
|
|
splitTelegramRichMarkdownChunks,
|
|
TELEGRAM_RICH_TEXT_LIMIT,
|
|
} from "./rich-message.js";
|
|
import { editMessageTelegram } from "./send.js";
|
|
import { getTelegramSequentialKey } from "./sequential-key.js";
|
|
import { cacheSticker, describeStickerImage } from "./sticker-cache.js";
|
|
import {
|
|
beginTelegramReplyFence,
|
|
buildTelegramNonInterruptingReplyFenceKey,
|
|
buildTelegramReplyFenceLaneKey,
|
|
endTelegramReplyFence,
|
|
getTelegramReplyFenceSizeForTests,
|
|
isTelegramReplyFenceSuperseded,
|
|
releaseTelegramReplyFenceAbortController,
|
|
resetTelegramReplyFenceForTests,
|
|
resolveTelegramReplyFenceKey,
|
|
shouldSupersedeTelegramReplyFence,
|
|
supersedeTelegramReplyFence,
|
|
} from "./telegram-reply-fence.js";
|
|
|
|
export { getTelegramReplyFenceSizeForTests, resetTelegramReplyFenceForTests };
|
|
|
|
const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again.";
|
|
const silentReplyDispatchLogger = createSubsystemLogger("telegram/silent-reply-dispatch");
|
|
|
|
/** Minimum chars before sending first streaming message (improves push notification UX) */
|
|
const DRAFT_MIN_INITIAL_CHARS = 30;
|
|
|
|
type DraftPartialTextUpdate = {
|
|
text: string;
|
|
delta?: string;
|
|
replace?: true;
|
|
isReasoningSnapshot?: boolean;
|
|
};
|
|
|
|
function resolveDraftPartialText(
|
|
previous: string,
|
|
update: DraftPartialTextUpdate,
|
|
): string | undefined {
|
|
const nextText =
|
|
update.replace || update.isReasoningSnapshot || update.delta === undefined
|
|
? update.text
|
|
: `${previous}${update.delta}`;
|
|
if (nextText === previous) {
|
|
return undefined;
|
|
}
|
|
return nextText;
|
|
}
|
|
|
|
function resolvePayloadTelegramInlineButtons(
|
|
payload: ReplyPayload,
|
|
): TelegramInlineButtons | undefined {
|
|
const telegramData = payload.channelData?.telegram as
|
|
| { buttons?: TelegramInlineButtons }
|
|
| undefined;
|
|
const presentation = normalizeMessagePresentation(payload.presentation);
|
|
return resolveTelegramInlineButtons({
|
|
buttons: telegramData?.buttons,
|
|
presentation,
|
|
interactive: payload.interactive,
|
|
});
|
|
}
|
|
|
|
function hasExecApprovalPayload(payload: ReplyPayload): boolean {
|
|
return payload.channelData?.execApproval !== undefined;
|
|
}
|
|
|
|
async function resolveStickerVisionSupport(cfg: OpenClawConfig, agentId: string) {
|
|
try {
|
|
const catalog = await loadModelCatalog({ config: cfg });
|
|
const defaultModel = resolveDefaultModelForAgent({ cfg, agentId });
|
|
const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model);
|
|
if (!entry) {
|
|
return false;
|
|
}
|
|
return modelSupportsVision(entry);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function includeStickerDescription(body: string | undefined, formattedDescription: string): string {
|
|
if (!body) {
|
|
return formattedDescription;
|
|
}
|
|
const current = body.trim();
|
|
if (!current || current === "<media:image>") {
|
|
return formattedDescription;
|
|
}
|
|
// Cached descriptions can already be present from inbound context construction.
|
|
// Keep that body intact so captions, forwarded text, and supplemental context survive.
|
|
if (body.includes(formattedDescription)) {
|
|
return body;
|
|
}
|
|
return `${formattedDescription}\n${body}`;
|
|
}
|
|
|
|
type DispatchTelegramMessageParams = {
|
|
context: TelegramMessageContext;
|
|
bot: Bot;
|
|
cfg: OpenClawConfig;
|
|
runtime: RuntimeEnv;
|
|
replyToMode: ReplyToMode;
|
|
streamMode: TelegramStreamMode;
|
|
textLimit: number;
|
|
telegramCfg: TelegramAccountConfig;
|
|
telegramDeps?: TelegramBotDeps;
|
|
opts: Pick<TelegramBotOptions, "token" | "mediaMaxMb">;
|
|
retryDispatchErrors?: boolean;
|
|
suppressFailureFallback?: boolean;
|
|
};
|
|
|
|
export type TelegramDispatchResult =
|
|
| { kind: "completed" }
|
|
| { kind: "failed-retryable"; error: unknown };
|
|
|
|
type TelegramReasoningLevel = "off" | "on" | "stream";
|
|
|
|
type TelegramTranscriptMirrorPayload = { text?: string; mediaUrls?: string[] };
|
|
type TelegramSessionStore = ReturnType<typeof loadSessionStore>;
|
|
type FreshTelegramSessionStoreLoader = ((agentId: string) => {
|
|
storePath: string;
|
|
store: TelegramSessionStore;
|
|
}) & {
|
|
clear: () => void;
|
|
};
|
|
|
|
function createFreshTelegramSessionStoreLoader(params: {
|
|
cfg: OpenClawConfig;
|
|
telegramDeps: TelegramBotDeps;
|
|
}): FreshTelegramSessionStoreLoader {
|
|
const storesByPath = new Map<string, TelegramSessionStore>();
|
|
const load = ((agentId: string) => {
|
|
const storePath = params.telegramDeps.resolveStorePath(params.cfg.session?.store, { agentId });
|
|
const cachedStore = storesByPath.get(storePath);
|
|
if (cachedStore) {
|
|
return { storePath, store: cachedStore };
|
|
}
|
|
const store = (params.telegramDeps.loadSessionStore ?? loadSessionStore)(storePath, {
|
|
skipCache: true,
|
|
});
|
|
storesByPath.set(storePath, store);
|
|
return { storePath, store };
|
|
}) as FreshTelegramSessionStoreLoader;
|
|
load.clear = () => storesByPath.clear();
|
|
return load;
|
|
}
|
|
|
|
function resolveTelegramReasoningLevel(params: {
|
|
cfg: OpenClawConfig;
|
|
sessionKey?: string;
|
|
agentId: string;
|
|
loadFreshSessionStore: FreshTelegramSessionStoreLoader;
|
|
}): TelegramReasoningLevel {
|
|
const { cfg, sessionKey, agentId } = params;
|
|
const configDefault = resolveTelegramConfigReasoningDefault(cfg, agentId);
|
|
if (!sessionKey) {
|
|
return configDefault;
|
|
}
|
|
try {
|
|
const { store } = params.loadFreshSessionStore(agentId);
|
|
const entry = resolveSessionStoreEntry({ store, sessionKey }).existing;
|
|
const level = entry?.reasoningLevel;
|
|
if (level === "on" || level === "stream" || level === "off") {
|
|
return level;
|
|
}
|
|
} catch {
|
|
return "off";
|
|
}
|
|
return configDefault;
|
|
}
|
|
|
|
function resolveTelegramMirroredTranscriptText(
|
|
payload: TelegramTranscriptMirrorPayload,
|
|
): string | null {
|
|
const mediaUrls = payload.mediaUrls?.filter((url) => url.trim()) ?? [];
|
|
if (mediaUrls.length > 0) {
|
|
return mediaUrls
|
|
.map((url) => {
|
|
const pathname = url.split("#")[0]?.split("?")[0] ?? url;
|
|
const base = path.basename(pathname);
|
|
return base && base !== "." && base !== "/" ? base : "media";
|
|
})
|
|
.join(", ");
|
|
}
|
|
|
|
const text = payload.text?.trim();
|
|
return text ? text : null;
|
|
}
|
|
|
|
async function mirrorTelegramAssistantReplyToTranscript(params: {
|
|
cfg: OpenClawConfig;
|
|
route: TelegramMessageContext["route"];
|
|
sessionKey: string;
|
|
loadFreshSessionStore: FreshTelegramSessionStoreLoader;
|
|
payload: TelegramTranscriptMirrorPayload;
|
|
}) {
|
|
const text = resolveTelegramMirroredTranscriptText(params.payload);
|
|
if (!text) {
|
|
return;
|
|
}
|
|
const { storePath, store } = params.loadFreshSessionStore(params.route.agentId);
|
|
const sessionEntry = resolveSessionStoreEntry({
|
|
store,
|
|
sessionKey: params.sessionKey,
|
|
}).existing;
|
|
if (!sessionEntry?.sessionId) {
|
|
return;
|
|
}
|
|
const { sessionFile } = await resolveAndPersistSessionFile({
|
|
sessionId: sessionEntry.sessionId,
|
|
sessionKey: params.sessionKey,
|
|
sessionStore: store,
|
|
storePath,
|
|
sessionEntry,
|
|
agentId: params.route.agentId,
|
|
sessionsDir: path.dirname(storePath),
|
|
});
|
|
const message = {
|
|
role: "assistant" as const,
|
|
content: [{ type: "text" as const, text }],
|
|
api: "openai-responses",
|
|
provider: "openclaw",
|
|
model: "delivery-mirror",
|
|
usage: {
|
|
input: 0,
|
|
output: 0,
|
|
total: 0,
|
|
prompt_tokens: 0,
|
|
completion_tokens: 0,
|
|
total_tokens: 0,
|
|
cache: {
|
|
read: 0,
|
|
write: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
total: 0,
|
|
},
|
|
},
|
|
stopReason: "stop" as const,
|
|
timestamp: Date.now(),
|
|
};
|
|
const {
|
|
appended,
|
|
messageId,
|
|
message: appendedMessage,
|
|
} = await appendSessionTranscriptMessage({
|
|
transcriptPath: sessionFile,
|
|
message,
|
|
config: params.cfg,
|
|
});
|
|
if (appended) {
|
|
const transcriptMarkerUpdatedAt = Date.now();
|
|
await updateSessionStoreEntry({
|
|
storePath,
|
|
sessionKey: params.sessionKey,
|
|
update: (current) =>
|
|
current.sessionId === sessionEntry.sessionId
|
|
? { updatedAt: transcriptMarkerUpdatedAt }
|
|
: null,
|
|
});
|
|
}
|
|
emitSessionTranscriptUpdate({
|
|
sessionFile,
|
|
sessionKey: params.sessionKey,
|
|
agentId: params.route.agentId,
|
|
message: appendedMessage,
|
|
messageId,
|
|
});
|
|
}
|
|
|
|
const MAX_PROGRESS_MARKDOWN_TEXT_CHARS = 300;
|
|
const TELEGRAM_GENERAL_TOPIC_ID = 1;
|
|
|
|
function clipProgressMarkdownText(text: string): string {
|
|
if (text.length <= MAX_PROGRESS_MARKDOWN_TEXT_CHARS) {
|
|
return text;
|
|
}
|
|
return `${text.slice(0, MAX_PROGRESS_MARKDOWN_TEXT_CHARS - 1).trimEnd()}…`;
|
|
}
|
|
|
|
function sanitizeProgressMarkdownText(text: string): string {
|
|
return text.replaceAll("`", "'");
|
|
}
|
|
|
|
function formatProgressAsMarkdownCode(text: string): string {
|
|
const clipped = clipProgressMarkdownText(text);
|
|
return `\`${sanitizeProgressMarkdownText(clipped)}\``;
|
|
}
|
|
|
|
function formatTelegramProgressLine(text: string): string {
|
|
const trimmed = text.trim();
|
|
return trimmed.startsWith("_") && trimmed.endsWith("_")
|
|
? trimmed
|
|
: formatProgressAsMarkdownCode(text);
|
|
}
|
|
|
|
function escapeTelegramProgressHtml(text: string): string {
|
|
return text
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """);
|
|
}
|
|
|
|
function renderTelegramProgressStringLine(text: string): string {
|
|
const clipped = clipProgressMarkdownText(text.trim());
|
|
const italic = clipped.match(/^_(.*)_$/u);
|
|
if (italic) {
|
|
return `<i>${escapeTelegramProgressHtml(italic[1] ?? "")}</i>`;
|
|
}
|
|
return `<code>${escapeTelegramProgressHtml(clipped)}</code>`;
|
|
}
|
|
|
|
function renderTelegramProgressLine(line: ChannelProgressDraftCompositorLine): string {
|
|
if (typeof line === "string") {
|
|
return line.split(/\r?\n/u).map(renderTelegramProgressStringLine).filter(Boolean).join("<br>");
|
|
}
|
|
if (!line.icon && line.label === "Commentary") {
|
|
return renderTelegramProgressStringLine(line.text);
|
|
}
|
|
const label = [line.icon, line.label].filter(Boolean).join(" ");
|
|
const parts = [`<b>${escapeTelegramProgressHtml(label)}</b>`];
|
|
const detail = line.detail && line.detail !== line.label ? line.detail : undefined;
|
|
if (detail) {
|
|
parts.push(`<code>${escapeTelegramProgressHtml(clipProgressMarkdownText(detail))}</code>`);
|
|
} else {
|
|
const text = line.text.trim();
|
|
if (text && text !== label) {
|
|
parts.push(renderTelegramProgressStringLine(text));
|
|
}
|
|
}
|
|
if (line.status && line.status !== "completed" && line.status !== line.detail) {
|
|
parts.push(`<i>${escapeTelegramProgressHtml(line.status)}</i>`);
|
|
}
|
|
return parts.join(" ");
|
|
}
|
|
|
|
function renderTelegramProgressDraftPreview(
|
|
text: string,
|
|
lines: readonly ChannelProgressDraftCompositorLine[],
|
|
): TelegramDraftPreview {
|
|
const trimmed = text.trimEnd();
|
|
const [heading] = trimmed.split(/\r?\n/u, 1);
|
|
const renderedLines = lines.map(renderTelegramProgressLine).filter(Boolean);
|
|
const htmlParts = heading?.trim()
|
|
? [`<b>${escapeTelegramProgressHtml(heading.trim())}</b>`, ...renderedLines]
|
|
: renderedLines;
|
|
return {
|
|
text: trimmed,
|
|
richMessage: buildTelegramRichHtml(htmlParts.join("<br>"), { skipEntityDetection: true }),
|
|
};
|
|
}
|
|
|
|
function normalizeTelegramThreadId(value: unknown): number | undefined {
|
|
return parseStrictPositiveInteger(value);
|
|
}
|
|
|
|
function resolveTelegramForumThreadScopeFromSessionKey(
|
|
sessionKey: unknown,
|
|
): { chatId: string; threadId: number } | undefined {
|
|
if (typeof sessionKey !== "string") {
|
|
return undefined;
|
|
}
|
|
const match = /:telegram:group:(-?\d+):topic:(\d+)(?::|$)/.exec(sessionKey);
|
|
const threadId = normalizeTelegramThreadId(match?.[2]);
|
|
if (!match?.[1] || threadId == null) {
|
|
return undefined;
|
|
}
|
|
return { chatId: match[1], threadId };
|
|
}
|
|
|
|
function resolveDispatchTelegramThreadSpec(params: {
|
|
chatId: TelegramMessageContext["chatId"];
|
|
ctxPayload: TelegramMessageContext["ctxPayload"];
|
|
threadSpec: TelegramThreadSpec;
|
|
}): TelegramThreadSpec {
|
|
if (
|
|
params.threadSpec.scope !== "forum" ||
|
|
(params.threadSpec.id != null && params.threadSpec.id !== TELEGRAM_GENERAL_TOPIC_ID)
|
|
) {
|
|
return params.threadSpec;
|
|
}
|
|
const scopedThread = resolveTelegramForumThreadScopeFromSessionKey(params.ctxPayload.SessionKey);
|
|
const scopedThreadId =
|
|
scopedThread?.chatId === String(params.chatId) ? scopedThread.threadId : undefined;
|
|
const payloadThreadId =
|
|
normalizeTelegramThreadId(params.ctxPayload.MessageThreadId) ??
|
|
normalizeTelegramThreadId(params.ctxPayload.TransportThreadId);
|
|
// Missing forum IDs are normalized to General; topic-scoped turn facts are more specific.
|
|
const recoveredThreadId = scopedThreadId ?? payloadThreadId;
|
|
return recoveredThreadId == null || recoveredThreadId === params.threadSpec.id
|
|
? params.threadSpec
|
|
: { ...params.threadSpec, id: recoveredThreadId };
|
|
}
|
|
|
|
function normalizeDispatchTelegramThreadPayload(params: {
|
|
context: TelegramMessageContext;
|
|
threadSpec: TelegramThreadSpec;
|
|
}): TelegramMessageContext {
|
|
if (params.threadSpec.scope !== "forum" || params.threadSpec.id == null) {
|
|
return params.context;
|
|
}
|
|
const messageThreadId = normalizeTelegramThreadId(params.context.ctxPayload.MessageThreadId);
|
|
const transportThreadId = normalizeTelegramThreadId(params.context.ctxPayload.TransportThreadId);
|
|
if (messageThreadId === params.threadSpec.id && transportThreadId === params.threadSpec.id) {
|
|
return params.context;
|
|
}
|
|
return {
|
|
...params.context,
|
|
ctxPayload: {
|
|
...params.context.ctxPayload,
|
|
MessageThreadId: params.threadSpec.id,
|
|
TransportThreadId: params.threadSpec.id,
|
|
},
|
|
};
|
|
}
|
|
|
|
function extractCurrentTelegramBody(body: string | undefined): string {
|
|
if (!body) {
|
|
return "";
|
|
}
|
|
const markerIndex = body.lastIndexOf(CURRENT_MESSAGE_MARKER);
|
|
if (markerIndex === -1) {
|
|
return body;
|
|
}
|
|
return body.slice(markerIndex + CURRENT_MESSAGE_MARKER.length).trimStart();
|
|
}
|
|
|
|
function includesRecoveredTelegramGroupHistoryContext(context: TelegramMessageContext): boolean {
|
|
return Boolean(
|
|
context.isGroup &&
|
|
context.groupHistoryContextMode &&
|
|
includesRecentTelegramGroupHistoryContext(context.groupHistoryContextMode),
|
|
);
|
|
}
|
|
|
|
function buildRecoveredTelegramBody(params: {
|
|
cfg: OpenClawConfig;
|
|
context: TelegramMessageContext;
|
|
currentMessage: string;
|
|
historyKey?: string;
|
|
threadSpec: TelegramThreadSpec;
|
|
}): string {
|
|
if (
|
|
!includesRecoveredTelegramGroupHistoryContext(params.context) ||
|
|
!params.historyKey ||
|
|
params.context.historyLimit <= 0
|
|
) {
|
|
return params.currentMessage;
|
|
}
|
|
const groupLabel = buildGroupLabel(
|
|
params.context.msg,
|
|
params.context.chatId,
|
|
params.threadSpec.id,
|
|
);
|
|
const envelopeOptions = resolveEnvelopeFormatOptions(params.cfg);
|
|
return createChannelHistoryWindow({
|
|
historyMap: params.context.groupHistories,
|
|
}).buildPendingContext({
|
|
historyKey: params.historyKey,
|
|
limit: params.context.historyLimit,
|
|
currentMessage: params.currentMessage,
|
|
formatEntry: (entry) =>
|
|
formatInboundEnvelope({
|
|
channel: "Telegram",
|
|
from: groupLabel,
|
|
timestamp: entry.timestamp,
|
|
body: `${entry.body} [id:${entry.messageId ?? "unknown"} chat:${params.context.chatId}]`,
|
|
chatType: "group",
|
|
senderLabel: entry.sender,
|
|
envelope: envelopeOptions,
|
|
}),
|
|
});
|
|
}
|
|
|
|
function buildRecoveredTelegramChatActionSender(params: {
|
|
context: TelegramMessageContext;
|
|
threadId?: number;
|
|
action: "typing" | "record_voice";
|
|
}): () => Promise<void> {
|
|
return async () => {
|
|
try {
|
|
await withTelegramApiErrorLogging({
|
|
operation: "sendChatAction",
|
|
fn: () =>
|
|
params.context.sendChatActionHandler.sendChatAction(
|
|
params.context.chatId,
|
|
params.action,
|
|
buildTypingThreadParams(params.threadId),
|
|
),
|
|
});
|
|
} catch (err) {
|
|
if (params.action !== "record_voice") {
|
|
throw err;
|
|
}
|
|
logVerbose(
|
|
`telegram record_voice cue failed for chat ${params.context.chatId}: ${String(err)}`,
|
|
);
|
|
}
|
|
};
|
|
}
|
|
|
|
function migrateRecoveredTelegramRoomEventHistory(params: {
|
|
context: TelegramMessageContext;
|
|
recoveredHistoryKey?: string;
|
|
}) {
|
|
const originalHistoryKey = params.context.historyKey;
|
|
const recoveredHistoryKey = params.recoveredHistoryKey;
|
|
if (
|
|
!includesRecoveredTelegramGroupHistoryContext(params.context) ||
|
|
params.context.ctxPayload.InboundEventKind !== "room_event" ||
|
|
!originalHistoryKey ||
|
|
!recoveredHistoryKey ||
|
|
originalHistoryKey === recoveredHistoryKey ||
|
|
params.context.historyLimit <= 0
|
|
) {
|
|
return;
|
|
}
|
|
const originalEntries = params.context.groupHistories.get(originalHistoryKey);
|
|
if (!originalEntries?.length) {
|
|
return;
|
|
}
|
|
const messageId = params.context.ctxPayload.MessageSid;
|
|
const rawBody = params.context.ctxPayload.RawBody;
|
|
const entryIndex = originalEntries.findLastIndex((entry) => {
|
|
if (messageId && entry.messageId === messageId) {
|
|
return true;
|
|
}
|
|
return !messageId && typeof rawBody === "string" && entry.body === rawBody;
|
|
});
|
|
if (entryIndex === -1) {
|
|
return;
|
|
}
|
|
const [entry] = originalEntries.splice(entryIndex, 1);
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
createChannelHistoryWindow({
|
|
historyMap: params.context.groupHistories,
|
|
}).record({
|
|
historyKey: recoveredHistoryKey,
|
|
limit: params.context.historyLimit,
|
|
entry,
|
|
});
|
|
}
|
|
|
|
function resolveDispatchTelegramContext(params: {
|
|
cfg: OpenClawConfig;
|
|
context: TelegramMessageContext;
|
|
}): TelegramMessageContext {
|
|
const threadSpec = resolveDispatchTelegramThreadSpec({
|
|
chatId: params.context.chatId,
|
|
ctxPayload: params.context.ctxPayload,
|
|
threadSpec: params.context.threadSpec,
|
|
});
|
|
if (threadSpec === params.context.threadSpec || threadSpec.scope !== "forum") {
|
|
return normalizeDispatchTelegramThreadPayload({ context: params.context, threadSpec });
|
|
}
|
|
const recoveredRoutingTarget = buildTelegramInboundOriginTarget(
|
|
params.context.chatId,
|
|
threadSpec,
|
|
);
|
|
const recoveredFrom = params.context.isGroup
|
|
? buildTelegramGroupFrom(params.context.chatId, threadSpec.id)
|
|
: params.context.ctxPayload.From;
|
|
const recoveredUpdateLastRoute =
|
|
params.context.turn.record.updateLastRoute && threadSpec.id != null
|
|
? {
|
|
...params.context.turn.record.updateLastRoute,
|
|
to: `telegram:${params.context.chatId}:topic:${threadSpec.id}`,
|
|
threadId: String(threadSpec.id),
|
|
}
|
|
: params.context.turn.record.updateLastRoute;
|
|
const recoveredHistoryKey = params.context.isGroup
|
|
? buildTelegramGroupPeerId(params.context.chatId, threadSpec.id)
|
|
: params.context.historyKey;
|
|
const includeRecoveredGroupHistory = includesRecoveredTelegramGroupHistoryContext(params.context);
|
|
migrateRecoveredTelegramRoomEventHistory({
|
|
context: params.context,
|
|
recoveredHistoryKey,
|
|
});
|
|
const recoveredInboundHistory =
|
|
includeRecoveredGroupHistory && recoveredHistoryKey && params.context.historyLimit > 0
|
|
? createChannelHistoryWindow({
|
|
historyMap: params.context.groupHistories,
|
|
}).buildInboundHistory({
|
|
historyKey: recoveredHistoryKey,
|
|
limit: params.context.historyLimit,
|
|
})
|
|
: params.context.ctxPayload.InboundHistory;
|
|
const recoveredBodyForAgent = extractCurrentTelegramBody(
|
|
params.context.ctxPayload.BodyForAgent ?? params.context.ctxPayload.Body,
|
|
);
|
|
const recoveredBody = buildRecoveredTelegramBody({
|
|
cfg: params.cfg,
|
|
context: params.context,
|
|
currentMessage: recoveredBodyForAgent,
|
|
historyKey: recoveredHistoryKey,
|
|
threadSpec,
|
|
});
|
|
const recoveredSendTyping = buildRecoveredTelegramChatActionSender({
|
|
context: params.context,
|
|
threadId: threadSpec.id,
|
|
action: "typing",
|
|
});
|
|
const recoveredSendRecordVoice = buildRecoveredTelegramChatActionSender({
|
|
context: params.context,
|
|
threadId: threadSpec.id,
|
|
action: "record_voice",
|
|
});
|
|
return {
|
|
...params.context,
|
|
historyKey: recoveredHistoryKey,
|
|
threadSpec,
|
|
resolvedThreadId: threadSpec.id,
|
|
replyThreadId: threadSpec.id,
|
|
sendTyping: recoveredSendTyping,
|
|
sendRecordVoice: recoveredSendRecordVoice,
|
|
turn: {
|
|
...params.context.turn,
|
|
record: {
|
|
...params.context.turn.record,
|
|
updateLastRoute: recoveredUpdateLastRoute,
|
|
},
|
|
},
|
|
ctxPayload:
|
|
threadSpec.id == null
|
|
? params.context.ctxPayload
|
|
: {
|
|
...params.context.ctxPayload,
|
|
Body: recoveredBody,
|
|
BodyForAgent: recoveredBodyForAgent,
|
|
From: recoveredFrom,
|
|
InboundHistory: recoveredInboundHistory,
|
|
MessageThreadId: threadSpec.id,
|
|
OriginatingTo: recoveredRoutingTarget,
|
|
To: recoveredRoutingTarget,
|
|
TransportThreadId: threadSpec.id,
|
|
},
|
|
};
|
|
}
|
|
|
|
export const dispatchTelegramMessage = async ({
|
|
context,
|
|
bot,
|
|
cfg,
|
|
runtime,
|
|
replyToMode,
|
|
streamMode,
|
|
textLimit,
|
|
telegramCfg,
|
|
telegramDeps: injectedTelegramDeps,
|
|
opts,
|
|
retryDispatchErrors = false,
|
|
suppressFailureFallback = false,
|
|
}: DispatchTelegramMessageParams): Promise<TelegramDispatchResult> => {
|
|
const dispatchStartedAt = Date.now();
|
|
const dispatchContext = resolveDispatchTelegramContext({ cfg, context });
|
|
const telegramDeps =
|
|
injectedTelegramDeps ?? (await import("./bot-deps.js")).defaultTelegramBotDeps;
|
|
const loadFreshSessionStore = createFreshTelegramSessionStoreLoader({ cfg, telegramDeps });
|
|
const {
|
|
ctxPayload,
|
|
msg,
|
|
chatId,
|
|
isGroup,
|
|
groupConfig,
|
|
topicConfig,
|
|
threadSpec,
|
|
historyKey,
|
|
historyLimit,
|
|
groupHistories,
|
|
route,
|
|
skillFilter,
|
|
sendTyping,
|
|
sendRecordVoice,
|
|
ackReactionPromise,
|
|
reactionApi,
|
|
removeAckAfterReply,
|
|
statusReactionController: rawStatusReactionController,
|
|
} = dispatchContext;
|
|
const isRoomEvent = ctxPayload.InboundEventKind === "room_event";
|
|
const statusReactionController = isRoomEvent ? null : rawStatusReactionController;
|
|
const statusReactionTiming = {
|
|
...DEFAULT_TIMING,
|
|
...cfg.messages?.statusReactions?.timing,
|
|
};
|
|
const clearTelegramStatusReaction = async () => {
|
|
if (!msg.message_id || !reactionApi) {
|
|
return;
|
|
}
|
|
await reactionApi(chatId, msg.message_id, []);
|
|
};
|
|
const finalizeTelegramStatusReaction = async (params: {
|
|
outcome: "done" | "error";
|
|
hasFinalResponse: boolean;
|
|
}) => {
|
|
if (!statusReactionController) {
|
|
return;
|
|
}
|
|
if (params.outcome === "done") {
|
|
await statusReactionController.setDone();
|
|
if (removeAckAfterReply) {
|
|
await sleepWithAbort(statusReactionTiming.doneHoldMs);
|
|
await clearTelegramStatusReaction();
|
|
} else {
|
|
await statusReactionController.restoreInitial();
|
|
}
|
|
return;
|
|
}
|
|
await statusReactionController.setError();
|
|
if (params.hasFinalResponse) {
|
|
if (removeAckAfterReply) {
|
|
await sleepWithAbort(statusReactionTiming.errorHoldMs);
|
|
await clearTelegramStatusReaction();
|
|
} else {
|
|
await statusReactionController.restoreInitial();
|
|
}
|
|
return;
|
|
}
|
|
if (removeAckAfterReply) {
|
|
await sleepWithAbort(statusReactionTiming.errorHoldMs);
|
|
}
|
|
await statusReactionController.restoreInitial();
|
|
};
|
|
const replyFenceKey = resolveTelegramReplyFenceKey({
|
|
ctxPayload,
|
|
chatId,
|
|
threadSpec,
|
|
});
|
|
const replyFenceLaneKey = getTelegramSequentialKey({
|
|
message: msg,
|
|
...(context.primaryCtx.me ? { me: context.primaryCtx.me } : {}),
|
|
});
|
|
const scopedReplyFenceLaneKey = buildTelegramReplyFenceLaneKey({
|
|
accountId: route.accountId,
|
|
sequentialKey: replyFenceLaneKey,
|
|
});
|
|
let activeReplyFenceKey = replyFenceKey.activeKey;
|
|
let replyFenceGeneration: number | undefined;
|
|
const replyAbortController = new AbortController();
|
|
let replyAbortControllerQueued = false;
|
|
let dispatchWasSuperseded;
|
|
const isDispatchSuperseded = () =>
|
|
replyFenceGeneration !== undefined &&
|
|
isTelegramReplyFenceSuperseded({
|
|
key: activeReplyFenceKey,
|
|
generation: replyFenceGeneration,
|
|
});
|
|
const releaseReplyFence = () => {
|
|
if (replyFenceGeneration === undefined) {
|
|
return;
|
|
}
|
|
endTelegramReplyFence(
|
|
activeReplyFenceKey,
|
|
replyAbortControllerQueued ? undefined : replyAbortController,
|
|
);
|
|
replyFenceGeneration = undefined;
|
|
};
|
|
// Block mode sizes preview rotation steps from streaming.preview.chunk (same
|
|
// contract as Discord's block chunker). Other modes keep one growing rich
|
|
// preview. The stream has no min-flush concept, so minChars/breakPreference
|
|
// do not apply here.
|
|
const draftMaxChars =
|
|
streamMode === "block"
|
|
? Math.min(resolveTelegramDraftStreamingChunking(cfg, route.accountId).maxChars, textLimit)
|
|
: Math.min(
|
|
textLimit,
|
|
telegramCfg.richMessages === true ? TELEGRAM_RICH_TEXT_LIMIT : TELEGRAM_TEXT_CHUNK_LIMIT,
|
|
);
|
|
const tableMode = resolveMarkdownTableMode({
|
|
cfg,
|
|
channel: "telegram",
|
|
accountId: route.accountId,
|
|
supportsBlockTables: telegramCfg.richMessages === true,
|
|
});
|
|
const renderStreamText = (text: string): TelegramDraftPreview =>
|
|
telegramCfg.richMessages === true
|
|
? {
|
|
text,
|
|
richMessage: buildTelegramRichMarkdown(text, {
|
|
tableMode,
|
|
skipEntityDetection: telegramCfg.linkPreview === false,
|
|
}),
|
|
}
|
|
: {
|
|
text: renderTelegramHtmlText(text, { tableMode }),
|
|
parseMode: "HTML",
|
|
};
|
|
const accountBlockStreamingEnabled =
|
|
resolveChannelStreamingBlockEnabled(telegramCfg) ??
|
|
cfg.agents?.defaults?.blockStreamingDefault === "on";
|
|
const resolvedReasoningLevel = resolveTelegramReasoningLevel({
|
|
cfg,
|
|
sessionKey: ctxPayload.SessionKey,
|
|
agentId: route.agentId,
|
|
loadFreshSessionStore,
|
|
});
|
|
const forceBlockStreamingForReasoning = resolvedReasoningLevel === "on";
|
|
const streamReasoningDraft = resolvedReasoningLevel === "stream";
|
|
const streamDeliveryEnabled = !isRoomEvent && streamMode !== "off";
|
|
const rawReplyQuoteText =
|
|
ctxPayload.ReplyToIsQuote && typeof ctxPayload.ReplyToQuoteText === "string"
|
|
? ctxPayload.ReplyToQuoteText
|
|
: undefined;
|
|
const replyQuoteText = ctxPayload.ReplyToIsQuote
|
|
? rawReplyQuoteText?.trim()
|
|
? rawReplyQuoteText
|
|
: ctxPayload.ReplyToBody?.trim() || undefined
|
|
: undefined;
|
|
const replyQuoteMessageId =
|
|
replyQuoteText && !ctxPayload.ReplyToIsExternal
|
|
? resolveTelegramReplyId(ctxPayload.ReplyToId)
|
|
: undefined;
|
|
const replyQuoteByMessageId: TelegramNativeQuoteCandidateByMessageId = {};
|
|
if (replyToMode !== "off") {
|
|
if (replyQuoteText && replyQuoteMessageId != null) {
|
|
addTelegramNativeQuoteCandidate(replyQuoteByMessageId, replyQuoteMessageId, {
|
|
text: replyQuoteText,
|
|
...(typeof ctxPayload.ReplyToQuotePosition === "number"
|
|
? { position: ctxPayload.ReplyToQuotePosition }
|
|
: {}),
|
|
...(Array.isArray(ctxPayload.ReplyToQuoteEntities)
|
|
? { entities: ctxPayload.ReplyToQuoteEntities }
|
|
: {}),
|
|
});
|
|
}
|
|
|
|
addTelegramNativeQuoteCandidate(
|
|
replyQuoteByMessageId,
|
|
ctxPayload.MessageSid ?? msg.message_id,
|
|
buildTelegramNativeQuoteCandidate(getTelegramTextParts(msg)),
|
|
);
|
|
|
|
if (!ctxPayload.ReplyToIsExternal && typeof ctxPayload.ReplyToQuoteSourceText === "string") {
|
|
addTelegramNativeQuoteCandidate(
|
|
replyQuoteByMessageId,
|
|
ctxPayload.ReplyToId,
|
|
buildTelegramNativeQuoteCandidate({
|
|
text: ctxPayload.ReplyToQuoteSourceText,
|
|
entities: Array.isArray(ctxPayload.ReplyToQuoteSourceEntities)
|
|
? ctxPayload.ReplyToQuoteSourceEntities
|
|
: undefined,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
const hasTelegramQuoteReply = replyToMode !== "off" && replyQuoteText != null;
|
|
const canStreamAnswerDraft =
|
|
streamDeliveryEnabled &&
|
|
!hasTelegramQuoteReply &&
|
|
!accountBlockStreamingEnabled &&
|
|
!forceBlockStreamingForReasoning;
|
|
const streamReasoningInProgressDraft =
|
|
streamReasoningDraft && streamMode === "progress" && canStreamAnswerDraft;
|
|
const canStreamReasoningDraft =
|
|
!isRoomEvent && streamReasoningDraft && !streamReasoningInProgressDraft;
|
|
const draftReplyToMessageId =
|
|
replyToMode !== "off" && typeof msg.message_id === "number"
|
|
? (replyQuoteMessageId ?? msg.message_id)
|
|
: undefined;
|
|
const draftMinInitialChars = streamMode === "progress" ? 0 : DRAFT_MIN_INITIAL_CHARS;
|
|
const progressSeed = `${route.accountId}:${chatId}:${threadSpec.id ?? ""}`;
|
|
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
|
|
const createDraftLane = (laneName: LaneName, enabled: boolean): DraftLaneState => {
|
|
const stream = enabled
|
|
? (telegramDeps.createTelegramDraftStream ?? createTelegramDraftStream)({
|
|
api: bot.api,
|
|
chatId,
|
|
maxChars: draftMaxChars,
|
|
thread: threadSpec,
|
|
replyToMessageId: draftReplyToMessageId,
|
|
richMessages: telegramCfg.richMessages,
|
|
minInitialChars: draftMinInitialChars,
|
|
renderText: renderStreamText,
|
|
onSupersededPreview: (superseded) => {
|
|
if (superseded.retain) {
|
|
lanes[laneName].activeChunkIndex += 1;
|
|
return;
|
|
}
|
|
void bot.api.deleteMessage(chatId, superseded.messageId).catch((err: unknown) => {
|
|
logVerbose(
|
|
`telegram: superseded ${laneName} stream cleanup failed (${superseded.messageId}): ${String(err)}`,
|
|
);
|
|
});
|
|
},
|
|
log: logVerbose,
|
|
warn: logVerbose,
|
|
})
|
|
: undefined;
|
|
return {
|
|
stream,
|
|
lastPartialText: "",
|
|
hasStreamedMessage: false,
|
|
finalized: false,
|
|
activeChunkIndex: 0,
|
|
};
|
|
};
|
|
const lanes: Record<LaneName, DraftLaneState> = {
|
|
answer: createDraftLane("answer", canStreamAnswerDraft),
|
|
reasoning: createDraftLane("reasoning", canStreamReasoningDraft),
|
|
};
|
|
const answerLane = lanes.answer;
|
|
const reasoningLane = lanes.reasoning;
|
|
let lastAnswerPartialText = "";
|
|
let activeAnswerDraftIsToolProgressOnly = false;
|
|
let activeAnswerBlockAssistantMessageIndex: number | undefined;
|
|
let lastAnswerBlockPayload: ReplyPayload | undefined;
|
|
let lastAnswerBlockText: string | undefined;
|
|
let lastAnswerBlockButtons: TelegramInlineButtons | undefined;
|
|
let materializeAnswerLaneBeforeRotation: (() => Promise<boolean>) | undefined;
|
|
type QueuedAnswerBlockRotation = {
|
|
assistantMessageIndex?: number;
|
|
text?: string;
|
|
shouldRotateBeforeDelivery: boolean;
|
|
};
|
|
const queuedAnswerBlockRotations: QueuedAnswerBlockRotation[] = [];
|
|
let queuedAnswerBlockAssistantMessageIndex: number | undefined;
|
|
let pendingAnswerBlockAssistantMessageIndex: number | undefined;
|
|
let rotateAnswerLaneWhenQueuedBlocksSettle = false;
|
|
function resetAnswerToolProgressDraft() {
|
|
activeAnswerDraftIsToolProgressOnly = false;
|
|
}
|
|
async function prepareAnswerLaneForToolProgress() {
|
|
if (answerLane.finalized) {
|
|
answerLane.stream?.forceNewMessage();
|
|
resetDraftLaneState(answerLane);
|
|
}
|
|
if (activeAnswerDraftIsToolProgressOnly) {
|
|
return;
|
|
}
|
|
if (answerLane.hasStreamedMessage) {
|
|
await rotateAnswerLaneForNewMessage();
|
|
}
|
|
activeAnswerDraftIsToolProgressOnly = true;
|
|
}
|
|
const progressDraft = createChannelProgressDraftCompositor({
|
|
entry: telegramCfg,
|
|
mode: streamMode,
|
|
active: Boolean(answerLane.stream),
|
|
seed: progressSeed,
|
|
formatLine: formatTelegramProgressLine,
|
|
update: async (streamText, options) => {
|
|
await prepareAnswerLaneForToolProgress();
|
|
answerLane.lastPartialText = streamText;
|
|
answerLane.hasStreamedMessage = true;
|
|
answerLane.finalized = false;
|
|
answerLane.stream?.updatePreview(
|
|
renderTelegramProgressDraftPreview(streamText, options?.lines ?? []),
|
|
);
|
|
if (options?.flush) {
|
|
await answerLane.stream?.flush();
|
|
}
|
|
},
|
|
});
|
|
let finalAnswerDeliveryStarted = false;
|
|
let finalAnswerDelivered = false;
|
|
// While the durable verbose lane is active, the ephemeral draft yields its
|
|
// commentary lines so they render once. Tool/plan status lines keep the
|
|
// draft: they have no durable counterpart in streamed runs.
|
|
let verboseProgressActive: () => boolean = () => false;
|
|
const pushStreamToolProgress = async (
|
|
line?: string | ChannelProgressDraftLine,
|
|
options?: { toolName?: string; startImmediately?: boolean },
|
|
) => {
|
|
if (
|
|
!answerLane.stream ||
|
|
answerLane.finalized ||
|
|
finalAnswerDeliveryStarted ||
|
|
finalAnswerDelivered
|
|
) {
|
|
return false;
|
|
}
|
|
return await progressDraft.pushToolProgress(line, options);
|
|
};
|
|
const pushStreamReasoningProgress = async (payload: {
|
|
text?: string;
|
|
isReasoningSnapshot?: boolean;
|
|
}) => {
|
|
return await progressDraft.pushReasoningProgress(payload.text, {
|
|
snapshot: payload.isReasoningSnapshot === true,
|
|
});
|
|
};
|
|
const markProgressFinalStarted = () => {
|
|
finalAnswerDeliveryStarted = true;
|
|
progressDraft.markFinalReplyStarted();
|
|
};
|
|
const markProgressFinalDelivered = () => {
|
|
finalAnswerDelivered = true;
|
|
progressDraft.markFinalReplyDelivered();
|
|
};
|
|
const resetProgressDraftState = () => {
|
|
progressDraft.reset();
|
|
};
|
|
const suppressProgressDraftState = () => {
|
|
progressDraft.suppress();
|
|
};
|
|
let splitReasoningOnNextStream = false;
|
|
let draftLaneEventQueue = Promise.resolve();
|
|
const reasoningStepState = createTelegramReasoningStepState();
|
|
const enqueueDraftLaneEvent = (task: () => Promise<void>): Promise<void> => {
|
|
const next = draftLaneEventQueue.then(async () => {
|
|
if (isDispatchSuperseded()) {
|
|
return;
|
|
}
|
|
await task();
|
|
});
|
|
draftLaneEventQueue = next.catch((err: unknown) => {
|
|
logVerbose(`telegram: draft lane callback failed: ${String(err)}`);
|
|
});
|
|
return draftLaneEventQueue;
|
|
};
|
|
type SplitLaneSegment = { lane: LaneName; update: DraftPartialTextUpdate };
|
|
type SplitLaneSegmentsResult = {
|
|
segments: SplitLaneSegment[];
|
|
suppressedReasoningOnly: boolean;
|
|
};
|
|
const splitTextIntoLaneSegments = (
|
|
update: { text?: string; delta?: string; replace?: true; isReasoningSnapshot?: boolean },
|
|
isReasoning?: boolean,
|
|
): SplitLaneSegmentsResult => {
|
|
const split = splitTelegramReasoningText(update.text, isReasoning);
|
|
const splitSegments: Array<{ lane: LaneName; text: string }> = [];
|
|
const useDelta =
|
|
!update.replace && update.isReasoningSnapshot !== true && update.delta !== undefined;
|
|
const segments: SplitLaneSegment[] = [];
|
|
const suppressReasoning = resolvedReasoningLevel === "off";
|
|
if (split.reasoningText && !suppressReasoning) {
|
|
splitSegments.push({ lane: "reasoning", text: split.reasoningText });
|
|
}
|
|
if (split.answerText) {
|
|
splitSegments.push({ lane: "answer", text: split.answerText });
|
|
}
|
|
for (const segment of splitSegments) {
|
|
const canApplyDelta = useDelta && splitSegments.length === 1;
|
|
segments.push({
|
|
lane: segment.lane,
|
|
update: {
|
|
text: segment.text,
|
|
...(canApplyDelta ? { delta: update.delta } : {}),
|
|
...(update.replace ? { replace: true } : {}),
|
|
...(update.isReasoningSnapshot ? { isReasoningSnapshot: true } : {}),
|
|
},
|
|
});
|
|
}
|
|
return {
|
|
segments,
|
|
suppressedReasoningOnly:
|
|
Boolean(split.reasoningText) && suppressReasoning && !split.answerText,
|
|
};
|
|
};
|
|
const resetDraftLaneState = (lane: DraftLaneState) => {
|
|
lane.lastPartialText = "";
|
|
if (lane === answerLane) {
|
|
lastAnswerPartialText = "";
|
|
}
|
|
lane.hasStreamedMessage = false;
|
|
lane.finalized = false;
|
|
lane.activeChunkIndex = 0;
|
|
if (lane === answerLane) {
|
|
resetAnswerToolProgressDraft();
|
|
pendingAnswerBlockAssistantMessageIndex = undefined;
|
|
lastAnswerBlockPayload = undefined;
|
|
lastAnswerBlockText = undefined;
|
|
lastAnswerBlockButtons = undefined;
|
|
}
|
|
};
|
|
const rotateLaneForNewMessage = async (lane: DraftLaneState) => {
|
|
if (!lane.hasStreamedMessage && typeof lane.stream?.messageId() !== "number") {
|
|
resetDraftLaneState(lane);
|
|
return;
|
|
}
|
|
await lane.stream?.stop();
|
|
lane.stream?.forceNewMessage();
|
|
resetDraftLaneState(lane);
|
|
};
|
|
const rotateAnswerLaneForNewMessage = async () => {
|
|
if (materializeAnswerLaneBeforeRotation) {
|
|
await materializeAnswerLaneBeforeRotation();
|
|
}
|
|
await rotateLaneForNewMessage(answerLane);
|
|
};
|
|
const rotateAnswerLaneAfterToolProgress = async () => {
|
|
if (!activeAnswerDraftIsToolProgressOnly) {
|
|
return false;
|
|
}
|
|
await answerLane.stream?.clear();
|
|
answerLane.stream?.forceNewMessage();
|
|
resetDraftLaneState(answerLane);
|
|
suppressProgressDraftState();
|
|
rotateAnswerLaneWhenQueuedBlocksSettle = false;
|
|
return true;
|
|
};
|
|
const rotateAnswerLaneAfterQueuedBlocksSettle = async () => {
|
|
if (!rotateAnswerLaneWhenQueuedBlocksSettle || queuedAnswerBlockRotations.length > 0) {
|
|
return false;
|
|
}
|
|
rotateAnswerLaneWhenQueuedBlocksSettle = false;
|
|
if (!answerLane.hasStreamedMessage || activeAnswerDraftIsToolProgressOnly) {
|
|
return false;
|
|
}
|
|
await rotateAnswerLaneForNewMessage();
|
|
return true;
|
|
};
|
|
const prepareAnswerLaneForText = async (): Promise<boolean> => {
|
|
if (await rotateAnswerLaneAfterToolProgress()) {
|
|
return true;
|
|
}
|
|
if (await rotateAnswerLaneAfterQueuedBlocksSettle()) {
|
|
return true;
|
|
}
|
|
if (!answerLane.finalized) {
|
|
return false;
|
|
}
|
|
answerLane.stream?.forceNewMessage();
|
|
resetDraftLaneState(answerLane);
|
|
rotateAnswerLaneWhenQueuedBlocksSettle = false;
|
|
return true;
|
|
};
|
|
const prepareQueuedAnswerBlock = async (
|
|
payload: ReplyPayload,
|
|
blockContext?: BlockReplyContext,
|
|
) => {
|
|
const hasAnswerText = splitTextIntoLaneSegments(
|
|
{ text: payload.text },
|
|
payload.isReasoning,
|
|
).segments.some((segment) => segment.lane === "answer");
|
|
if (!hasAnswerText) {
|
|
return;
|
|
}
|
|
resetProgressDraftState();
|
|
const assistantMessageIndex = blockContext?.assistantMessageIndex;
|
|
if (assistantMessageIndex === undefined) {
|
|
queuedAnswerBlockRotations.push({
|
|
text: payload.text,
|
|
shouldRotateBeforeDelivery: false,
|
|
});
|
|
return;
|
|
}
|
|
const previousAssistantMessageIndex =
|
|
queuedAnswerBlockAssistantMessageIndex ??
|
|
activeAnswerBlockAssistantMessageIndex ??
|
|
pendingAnswerBlockAssistantMessageIndex;
|
|
const shouldRotateBeforeDelivery =
|
|
previousAssistantMessageIndex !== undefined &&
|
|
assistantMessageIndex !== previousAssistantMessageIndex;
|
|
queuedAnswerBlockRotations.push({
|
|
assistantMessageIndex,
|
|
text: payload.text,
|
|
shouldRotateBeforeDelivery,
|
|
});
|
|
queuedAnswerBlockAssistantMessageIndex = assistantMessageIndex;
|
|
};
|
|
const recomputeQueuedAnswerBlockRotations = () => {
|
|
let previousAssistantMessageIndex =
|
|
activeAnswerBlockAssistantMessageIndex ?? pendingAnswerBlockAssistantMessageIndex;
|
|
queuedAnswerBlockAssistantMessageIndex = undefined;
|
|
for (const entry of queuedAnswerBlockRotations) {
|
|
if (entry.assistantMessageIndex === undefined) {
|
|
continue;
|
|
}
|
|
entry.shouldRotateBeforeDelivery =
|
|
previousAssistantMessageIndex !== undefined &&
|
|
entry.assistantMessageIndex !== previousAssistantMessageIndex;
|
|
previousAssistantMessageIndex = entry.assistantMessageIndex;
|
|
queuedAnswerBlockAssistantMessageIndex = entry.assistantMessageIndex;
|
|
}
|
|
};
|
|
const queuedAnswerBlockRotationTextMatchesPayload = (
|
|
entry: QueuedAnswerBlockRotation,
|
|
payload: ReplyPayload,
|
|
) => {
|
|
return entry.text !== undefined && payload.text !== undefined && entry.text === payload.text;
|
|
};
|
|
const queuedAnswerBlockRotationMatchesDelivery = (
|
|
entry: QueuedAnswerBlockRotation,
|
|
payload: ReplyPayload,
|
|
assistantMessageIndex?: number,
|
|
) => {
|
|
if (assistantMessageIndex !== undefined && entry.assistantMessageIndex !== undefined) {
|
|
return assistantMessageIndex === entry.assistantMessageIndex;
|
|
}
|
|
return queuedAnswerBlockRotationTextMatchesPayload(entry, payload);
|
|
};
|
|
const takeQueuedAnswerBlockRotation = (
|
|
payload: ReplyPayload,
|
|
assistantMessageIndex?: number,
|
|
): boolean => {
|
|
if (queuedAnswerBlockRotations.length === 0) {
|
|
return false;
|
|
}
|
|
const matchIndex = queuedAnswerBlockRotations.findIndex((entry) =>
|
|
queuedAnswerBlockRotationMatchesDelivery(entry, payload, assistantMessageIndex),
|
|
);
|
|
const consumeIndex = Math.max(matchIndex, 0);
|
|
const matchedEntries = queuedAnswerBlockRotations.splice(0, consumeIndex + 1);
|
|
const matchedEntry = matchedEntries.at(-1);
|
|
const shouldRotateBeforeDelivery = matchedEntry?.shouldRotateBeforeDelivery ?? false;
|
|
if (matchedEntry?.assistantMessageIndex !== undefined) {
|
|
activeAnswerBlockAssistantMessageIndex = matchedEntry.assistantMessageIndex;
|
|
pendingAnswerBlockAssistantMessageIndex = undefined;
|
|
}
|
|
recomputeQueuedAnswerBlockRotations();
|
|
return shouldRotateBeforeDelivery;
|
|
};
|
|
const dropQueuedAnswerBlockRotation = (payload: ReplyPayload, assistantMessageIndex?: number) => {
|
|
let matchIndex = queuedAnswerBlockRotations.findIndex((entry) =>
|
|
queuedAnswerBlockRotationMatchesDelivery(entry, payload, assistantMessageIndex),
|
|
);
|
|
if (matchIndex < 0 && assistantMessageIndex === undefined) {
|
|
matchIndex = queuedAnswerBlockRotations.findIndex(
|
|
(entry) => entry.assistantMessageIndex === undefined,
|
|
);
|
|
}
|
|
if (matchIndex >= 0) {
|
|
const matchedEntry = queuedAnswerBlockRotations[matchIndex];
|
|
queuedAnswerBlockRotations.splice(matchIndex, 1);
|
|
if (
|
|
matchIndex === 0 &&
|
|
matchedEntry?.assistantMessageIndex !== undefined &&
|
|
rotateAnswerLaneWhenQueuedBlocksSettle &&
|
|
activeAnswerBlockAssistantMessageIndex === undefined &&
|
|
answerLane.hasStreamedMessage
|
|
) {
|
|
pendingAnswerBlockAssistantMessageIndex = matchedEntry.assistantMessageIndex;
|
|
}
|
|
recomputeQueuedAnswerBlockRotations();
|
|
}
|
|
};
|
|
const updateDraftFromPartial = (lane: DraftLaneState, update: DraftPartialTextUpdate) => {
|
|
const laneStream = lane.stream;
|
|
if (!laneStream || !update.text) {
|
|
return;
|
|
}
|
|
const previousText = lane === answerLane ? lastAnswerPartialText : lane.lastPartialText;
|
|
const nextText = resolveDraftPartialText(previousText, update);
|
|
if (!nextText) {
|
|
return;
|
|
}
|
|
if (lane === answerLane) {
|
|
if (streamMode === "progress") {
|
|
return;
|
|
}
|
|
resetAnswerToolProgressDraft();
|
|
suppressProgressDraftState();
|
|
}
|
|
lane.hasStreamedMessage = true;
|
|
lane.finalized = false;
|
|
if (lane === answerLane) {
|
|
lastAnswerPartialText = nextText;
|
|
}
|
|
lane.lastPartialText = nextText;
|
|
laneStream.update(nextText);
|
|
};
|
|
const ingestDraftLaneSegments = async (
|
|
update: { text?: string; delta?: string; replace?: true; isReasoningSnapshot?: boolean },
|
|
isReasoning?: boolean,
|
|
) => {
|
|
const split = splitTextIntoLaneSegments(update, isReasoning);
|
|
for (const segment of split.segments) {
|
|
if (segment.lane === "answer") {
|
|
await prepareAnswerLaneForText();
|
|
}
|
|
if (segment.lane === "reasoning") {
|
|
reasoningStepState.noteReasoningHint();
|
|
reasoningStepState.noteReasoningDelivered();
|
|
}
|
|
updateDraftFromPartial(lanes[segment.lane], segment.update);
|
|
}
|
|
};
|
|
const flushDraftLane = async (lane: DraftLaneState) => {
|
|
if (!lane.stream) {
|
|
return;
|
|
}
|
|
await lane.stream.flush();
|
|
};
|
|
|
|
const resolvedBlockStreamingEnabled = resolveChannelStreamingBlockEnabled(telegramCfg);
|
|
const disableBlockStreaming = !streamDeliveryEnabled
|
|
? true
|
|
: forceBlockStreamingForReasoning
|
|
? false
|
|
: typeof resolvedBlockStreamingEnabled === "boolean"
|
|
? !resolvedBlockStreamingEnabled
|
|
: canStreamAnswerDraft
|
|
? true
|
|
: undefined;
|
|
|
|
const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId);
|
|
|
|
const supersedeReplyFence = shouldSupersedeTelegramReplyFence(ctxPayload);
|
|
activeReplyFenceKey = supersedeReplyFence
|
|
? replyFenceKey.activeKey
|
|
: buildTelegramNonInterruptingReplyFenceKey({
|
|
activeKey: replyFenceKey.activeKey,
|
|
laneKey: scopedReplyFenceLaneKey,
|
|
});
|
|
if (!isRoomEvent && supersedeReplyFence) {
|
|
supersedeTelegramReplyFence(replyFenceKey.roomEventKey);
|
|
}
|
|
replyFenceGeneration = beginTelegramReplyFence({
|
|
key: activeReplyFenceKey,
|
|
supersede: supersedeReplyFence,
|
|
abortController: replyAbortController,
|
|
laneKey: scopedReplyFenceLaneKey,
|
|
});
|
|
|
|
const implicitQuoteReplyTargetId =
|
|
replyQuoteMessageId != null ? String(replyQuoteMessageId) : undefined;
|
|
const currentMessageIdForQuoteReply =
|
|
implicitQuoteReplyTargetId && ctxPayload.MessageSid ? ctxPayload.MessageSid : undefined;
|
|
const replyQuotePosition =
|
|
typeof ctxPayload.ReplyToQuotePosition === "number"
|
|
? ctxPayload.ReplyToQuotePosition
|
|
: undefined;
|
|
const replyQuoteEntities = Array.isArray(ctxPayload.ReplyToQuoteEntities)
|
|
? ctxPayload.ReplyToQuoteEntities
|
|
: undefined;
|
|
const deliveryState = createLaneDeliveryStateTracker();
|
|
const clearGroupHistory = () => {
|
|
if (isGroup && historyKey) {
|
|
createChannelHistoryWindow({ historyMap: groupHistories }).clear({
|
|
historyKey,
|
|
limit: historyLimit,
|
|
});
|
|
}
|
|
};
|
|
const beginDeliveryCorrelation = () =>
|
|
beginTelegramInboundEventDeliveryCorrelation(
|
|
ctxPayload.SessionKey,
|
|
{
|
|
outboundTo: historyKey || String(chatId),
|
|
outboundAccountId: route.accountId,
|
|
markInboundEventDelivered: () => {
|
|
deliveryState.markDelivered();
|
|
if (isRoomEvent) {
|
|
clearGroupHistory();
|
|
}
|
|
},
|
|
},
|
|
{ inboundEventKind: ctxPayload.InboundEventKind },
|
|
);
|
|
const endTelegramInboundEventDeliveryCorrelation = beginDeliveryCorrelation();
|
|
const sessionKey = ctxPayload.SessionKey;
|
|
const resolveCurrentTurnTranscriptFinalText = async (): Promise<string | undefined> => {
|
|
if (!sessionKey) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
const { storePath, store } = loadFreshSessionStore(route.agentId);
|
|
const sessionEntry = resolveSessionStoreEntry({
|
|
store,
|
|
sessionKey,
|
|
}).existing;
|
|
if (!sessionEntry?.sessionId) {
|
|
return undefined;
|
|
}
|
|
const { sessionFile } = await resolveAndPersistSessionFile({
|
|
sessionId: sessionEntry.sessionId,
|
|
sessionKey,
|
|
sessionStore: store,
|
|
storePath,
|
|
sessionEntry,
|
|
agentId: route.agentId,
|
|
sessionsDir: path.dirname(storePath),
|
|
});
|
|
const latest = await readLatestAssistantTextFromSessionTranscript(sessionFile);
|
|
if (!latest?.timestamp || latest.timestamp < dispatchStartedAt) {
|
|
return undefined;
|
|
}
|
|
return latest.text;
|
|
} catch (err) {
|
|
logVerbose(`telegram transcript final candidate lookup failed: ${formatErrorMessage(err)}`);
|
|
return undefined;
|
|
}
|
|
};
|
|
const deliveryBaseOptions = {
|
|
chatId: String(chatId),
|
|
accountId: route.accountId,
|
|
sessionKeyForInternalHooks: ctxPayload.SessionKey,
|
|
mirrorIsGroup: isGroup,
|
|
mirrorGroupId: isGroup ? String(chatId) : undefined,
|
|
token: opts.token,
|
|
runtime,
|
|
bot,
|
|
mediaLocalRoots,
|
|
mediaMaxBytes: (opts.mediaMaxMb ?? telegramCfg.mediaMaxMb ?? 100) * 1024 * 1024,
|
|
replyToMode,
|
|
textLimit,
|
|
thread: threadSpec,
|
|
tableMode,
|
|
chunkMode,
|
|
richMessages: telegramCfg.richMessages,
|
|
linkPreview: telegramCfg.linkPreview,
|
|
replyQuoteMessageId,
|
|
replyQuoteText,
|
|
replyQuotePosition,
|
|
replyQuoteEntities,
|
|
replyQuoteByMessageId,
|
|
transcriptMirror: sessionKey
|
|
? async (payload: TelegramTranscriptMirrorPayload) => {
|
|
await mirrorTelegramAssistantReplyToTranscript({
|
|
cfg,
|
|
route,
|
|
sessionKey,
|
|
loadFreshSessionStore,
|
|
payload,
|
|
});
|
|
}
|
|
: undefined,
|
|
};
|
|
const silentErrorReplies = telegramCfg.silentErrorReplies === true;
|
|
const isDmTopic = !isGroup && threadSpec.scope === "dm" && threadSpec.id != null;
|
|
let queuedFinal = false;
|
|
let skippedDuplicateAnswerBlockDraftDelivery = false;
|
|
let suppressSilentReplyFallback = false;
|
|
let hadErrorReplyFailureOrSkip = false;
|
|
let isFirstTurnInSession = false;
|
|
let dispatchError: unknown;
|
|
|
|
try {
|
|
const sticker = ctxPayload.Sticker;
|
|
if (sticker?.fileId && sticker.fileUniqueId && ctxPayload.MediaPath) {
|
|
const agentDir = resolveAgentDir(cfg, route.agentId);
|
|
const stickerSupportsVision = await resolveStickerVisionSupport(cfg, route.agentId);
|
|
let description = sticker.cachedDescription ?? null;
|
|
if (!description) {
|
|
description = await describeStickerImage({
|
|
imagePath: ctxPayload.MediaPath,
|
|
cfg,
|
|
agentDir,
|
|
agentId: route.agentId,
|
|
});
|
|
}
|
|
if (description) {
|
|
const stickerContext = [sticker.emoji, sticker.setName ? `from "${sticker.setName}"` : null]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
const formattedDesc = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${description}`;
|
|
|
|
sticker.cachedDescription = description;
|
|
if (!stickerSupportsVision) {
|
|
ctxPayload.Body = includeStickerDescription(ctxPayload.Body, formattedDesc);
|
|
ctxPayload.BodyForAgent = includeStickerDescription(
|
|
ctxPayload.BodyForAgent,
|
|
formattedDesc,
|
|
);
|
|
ctxPayload.SkipStickerMediaUnderstanding = true;
|
|
}
|
|
cacheSticker({
|
|
fileId: sticker.fileId,
|
|
fileUniqueId: sticker.fileUniqueId,
|
|
emoji: sticker.emoji,
|
|
setName: sticker.setName,
|
|
description,
|
|
cachedAt: new Date().toISOString(),
|
|
receivedFrom: ctxPayload.From,
|
|
});
|
|
logVerbose(`telegram: cached sticker description for ${sticker.fileUniqueId}`);
|
|
}
|
|
}
|
|
|
|
const applyTextToPayload = (payload: ReplyPayload, text: string): ReplyPayload => {
|
|
if (payload.text === text) {
|
|
return payload;
|
|
}
|
|
return { ...payload, text };
|
|
};
|
|
const applyTextToFollowUpPayload = (payload: ReplyPayload, text: string): ReplyPayload => {
|
|
const next = applyTextToPayload(payload, text);
|
|
const {
|
|
replyToId: _replyToId,
|
|
replyToCurrent: _replyToCurrent,
|
|
replyToTag: _replyToTag,
|
|
...followUp
|
|
} = next;
|
|
return followUp;
|
|
};
|
|
const splitFinalTextForStream = (text: string): string[] => {
|
|
return splitTelegramRichMarkdownChunks(text, draftMaxChars, chunkMode);
|
|
};
|
|
const applyQuoteReplyTarget = (payload: ReplyPayload): ReplyPayload => {
|
|
if (
|
|
!implicitQuoteReplyTargetId ||
|
|
!currentMessageIdForQuoteReply ||
|
|
payload.replyToId !== currentMessageIdForQuoteReply ||
|
|
payload.replyToTag ||
|
|
payload.replyToCurrent
|
|
) {
|
|
return payload;
|
|
}
|
|
return { ...payload, replyToId: implicitQuoteReplyTargetId };
|
|
};
|
|
const normalizeDeliveryPayload = (payload: ReplyPayload): ReplyPayload | undefined => {
|
|
return projectOutboundPayloadPlanForDelivery(
|
|
createOutboundPayloadPlan([payload], {
|
|
cfg,
|
|
sessionKey: ctxPayload.SessionKey,
|
|
surface: "telegram",
|
|
}),
|
|
)[0];
|
|
};
|
|
const usesNativeTelegramQuote = (payload: ReplyPayload): boolean => {
|
|
if (replyQuoteText != null) {
|
|
return true;
|
|
}
|
|
return payload.replyToId != null && replyQuoteByMessageId[payload.replyToId] != null;
|
|
};
|
|
const sendPayload = async (
|
|
payload: ReplyPayload,
|
|
options?: { durable?: boolean; silent?: boolean },
|
|
) => {
|
|
if (isDispatchSuperseded()) {
|
|
return false;
|
|
}
|
|
const deliverablePayload = applyQuoteReplyTarget(payload);
|
|
const silent = options?.silent ?? (silentErrorReplies && payload.isError === true);
|
|
const durableDelivery = telegramDeps.deliverInboundReplyWithMessageSendContext;
|
|
if (options?.durable && durableDelivery) {
|
|
const durable = await durableDelivery({
|
|
cfg,
|
|
channel: "telegram",
|
|
to: String(chatId),
|
|
accountId: route.accountId,
|
|
agentId: route.agentId,
|
|
ctxPayload,
|
|
payload: deliverablePayload,
|
|
info: { kind: "final" },
|
|
replyToMode,
|
|
threadId: threadSpec.id,
|
|
formatting: {
|
|
textLimit,
|
|
tableMode,
|
|
chunkMode,
|
|
},
|
|
silent,
|
|
requiredCapabilities: deriveDurableFinalDeliveryRequirements({
|
|
payload: deliverablePayload,
|
|
replyToId: deliverablePayload.replyToId,
|
|
threadId: threadSpec.id,
|
|
silent,
|
|
payloadTransport: true,
|
|
extraCapabilities: {
|
|
nativeQuote: usesNativeTelegramQuote(deliverablePayload),
|
|
},
|
|
}),
|
|
});
|
|
if (durable.status === "failed") {
|
|
throw durable.error;
|
|
}
|
|
if (durable.status === "handled_visible") {
|
|
deliveryState.markDelivered();
|
|
return true;
|
|
}
|
|
if (durable.status === "handled_no_send") {
|
|
return false;
|
|
}
|
|
}
|
|
const result = await (telegramDeps.deliverReplies ?? deliverReplies)({
|
|
...deliveryBaseOptions,
|
|
transcriptMirror: options?.durable ? deliveryBaseOptions.transcriptMirror : undefined,
|
|
replies: [deliverablePayload],
|
|
onVoiceRecording: sendRecordVoice,
|
|
silent,
|
|
mediaLoader: telegramDeps.loadWebMedia,
|
|
});
|
|
if (result.delivered) {
|
|
deliveryState.markDelivered();
|
|
}
|
|
return result.delivered;
|
|
};
|
|
const emitPreviewFinalizedHook = async (result: LaneDeliveryResult) => {
|
|
if (isDispatchSuperseded() || result.kind !== "preview-finalized") {
|
|
return;
|
|
}
|
|
(telegramDeps.emitInternalMessageSentHook ?? emitInternalMessageSentHook)({
|
|
sessionKeyForInternalHooks: deliveryBaseOptions.sessionKeyForInternalHooks,
|
|
chatId: deliveryBaseOptions.chatId,
|
|
accountId: deliveryBaseOptions.accountId,
|
|
content: result.delivery.content,
|
|
success: true,
|
|
messageId: result.delivery.messageId,
|
|
isGroup: deliveryBaseOptions.mirrorIsGroup,
|
|
groupId: deliveryBaseOptions.mirrorGroupId,
|
|
});
|
|
try {
|
|
await (
|
|
telegramDeps.recordOutboundMessageForPromptContext ??
|
|
recordOutboundMessageForPromptContext
|
|
)({
|
|
cfg,
|
|
account: { accountId: route.accountId },
|
|
chatId: deliveryBaseOptions.chatId,
|
|
message: { message_id: result.delivery.messageId },
|
|
messageId: result.delivery.messageId,
|
|
text: result.delivery.promptContextContent ?? result.delivery.content,
|
|
...(threadSpec.id !== undefined ? { messageThreadId: threadSpec.id } : {}),
|
|
});
|
|
} catch (error) {
|
|
logVerbose(
|
|
`telegram: failed to record streamed reply for prompt context: ${formatErrorMessage(
|
|
error,
|
|
)}`,
|
|
);
|
|
}
|
|
if (deliveryBaseOptions.transcriptMirror && result.delivery.content) {
|
|
void deliveryBaseOptions
|
|
.transcriptMirror({ text: result.delivery.content })
|
|
.catch((err: unknown) => {
|
|
logVerbose(
|
|
`telegram preview-finalized transcriptMirror failed: ${formatErrorMessage(err)}`,
|
|
);
|
|
});
|
|
}
|
|
};
|
|
const finalizeSkippedDuplicateAnswerBlockDraft = async () => {
|
|
if (
|
|
!skippedDuplicateAnswerBlockDraftDelivery ||
|
|
queuedFinal ||
|
|
dispatchError ||
|
|
isDispatchSuperseded() ||
|
|
answerLane.finalized
|
|
) {
|
|
return;
|
|
}
|
|
const stream = answerLane.stream;
|
|
const content = answerLane.lastPartialText;
|
|
if (!stream || !content) {
|
|
return;
|
|
}
|
|
await stream.stop();
|
|
const messageId = stream.messageId();
|
|
if (typeof messageId !== "number") {
|
|
if (stream.sendMayHaveLanded?.()) {
|
|
answerLane.finalized = true;
|
|
deliveryState.markDelivered();
|
|
}
|
|
return;
|
|
}
|
|
answerLane.finalized = true;
|
|
deliveryState.markDelivered();
|
|
await emitPreviewFinalizedHook({
|
|
kind: "preview-finalized",
|
|
delivery: {
|
|
content,
|
|
promptContextContent: content,
|
|
messageId,
|
|
buttonsAttached: false,
|
|
receipt: createPreviewMessageReceipt({ id: messageId }),
|
|
},
|
|
});
|
|
};
|
|
const deliverLaneText = createLaneTextDeliverer({
|
|
lanes,
|
|
draftMaxChars,
|
|
applyTextToPayload,
|
|
applyTextToFollowUpPayload,
|
|
splitFinalTextForStream,
|
|
sendPayload,
|
|
flushDraftLane,
|
|
stopDraftLane: async (lane) => {
|
|
await lane.stream?.stop();
|
|
},
|
|
clearDraftLane: async (lane) => {
|
|
await lane.stream?.clear();
|
|
},
|
|
editStreamMessage: async ({ messageId, text, buttons }) => {
|
|
if (isDispatchSuperseded()) {
|
|
return;
|
|
}
|
|
await (telegramDeps.editMessageTelegram ?? editMessageTelegram)(chatId, messageId, text, {
|
|
api: bot.api,
|
|
cfg,
|
|
accountId: route.accountId,
|
|
linkPreview: telegramCfg.linkPreview,
|
|
buttons,
|
|
});
|
|
},
|
|
resolveFinalTextCandidate: () => resolveCurrentTurnTranscriptFinalText(),
|
|
log: logVerbose,
|
|
markDelivered: () => {
|
|
deliveryState.markDelivered();
|
|
},
|
|
});
|
|
materializeAnswerLaneBeforeRotation = async () => {
|
|
if (
|
|
!lastAnswerBlockPayload ||
|
|
!answerLane.stream ||
|
|
!answerLane.hasStreamedMessage ||
|
|
answerLane.finalized ||
|
|
activeAnswerDraftIsToolProgressOnly
|
|
) {
|
|
return false;
|
|
}
|
|
const text = answerLane.lastPartialText || lastAnswerPartialText || lastAnswerBlockText;
|
|
if (!text?.trim()) {
|
|
return false;
|
|
}
|
|
// Skipped duplicate blocks must materialize before the next draft takes over.
|
|
const wasSkippedDuplicate = skippedDuplicateAnswerBlockDraftDelivery;
|
|
skippedDuplicateAnswerBlockDraftDelivery = false;
|
|
const deliveredText = answerLane.stream.lastDeliveredText?.();
|
|
const messageId = answerLane.stream.messageId();
|
|
if (
|
|
!lastAnswerBlockButtons &&
|
|
!wasSkippedDuplicate &&
|
|
deliveredText === text.trimEnd() &&
|
|
typeof messageId === "number"
|
|
) {
|
|
await answerLane.stream.stop();
|
|
answerLane.finalized = true;
|
|
deliveryState.markDelivered();
|
|
await emitPreviewFinalizedHook({
|
|
kind: "preview-finalized",
|
|
delivery: {
|
|
content: text,
|
|
promptContextContent: deliveredText,
|
|
messageId,
|
|
receipt: createPreviewMessageReceipt({ id: messageId }),
|
|
},
|
|
});
|
|
return true;
|
|
}
|
|
const result = await deliverLaneText({
|
|
laneName: "answer",
|
|
text,
|
|
payload: lastAnswerBlockPayload,
|
|
infoKind: "block",
|
|
buttons: lastAnswerBlockButtons,
|
|
finalizePreview: true,
|
|
durable: false,
|
|
});
|
|
await emitPreviewFinalizedHook(result);
|
|
return result.kind !== "skipped";
|
|
};
|
|
const deliverProgressModeFinalAnswer = async (
|
|
payload: ReplyPayload,
|
|
text: string,
|
|
): Promise<LaneDeliveryResult> => {
|
|
if (activeAnswerDraftIsToolProgressOnly) {
|
|
await rotateAnswerLaneAfterToolProgress();
|
|
} else {
|
|
await answerLane.stream?.clear();
|
|
resetDraftLaneState(answerLane);
|
|
}
|
|
const delivered = await sendPayload(applyTextToPayload(payload, text), { durable: true });
|
|
if (!delivered) {
|
|
return { kind: "skipped" };
|
|
}
|
|
answerLane.finalized = true;
|
|
markProgressFinalDelivered();
|
|
return { kind: "sent" };
|
|
};
|
|
const resolveTranscriptBackedFinalText = async (text: string): Promise<string> =>
|
|
await resolveTranscriptBackedChannelFinalText({
|
|
finalText: text,
|
|
resolveCandidateText: resolveCurrentTurnTranscriptFinalText,
|
|
});
|
|
|
|
if (isDmTopic) {
|
|
try {
|
|
const { store } = loadFreshSessionStore(route.agentId);
|
|
const sessionKeyLocal = ctxPayload.SessionKey;
|
|
if (sessionKeyLocal) {
|
|
const entry = resolveSessionStoreEntry({ store, sessionKey: sessionKeyLocal }).existing;
|
|
isFirstTurnInSession = !entry?.systemSent;
|
|
} else {
|
|
logVerbose("auto-topic-label: SessionKey is absent, skipping first-turn detection");
|
|
}
|
|
} catch (err) {
|
|
logVerbose(`auto-topic-label: session store error: ${formatErrorMessage(err)}`);
|
|
}
|
|
}
|
|
loadFreshSessionStore.clear();
|
|
|
|
if (statusReactionController && !isRoomEvent) {
|
|
void statusReactionController.setThinking();
|
|
}
|
|
|
|
const { onModelSelected, ...replyPipeline } = (
|
|
telegramDeps.createChannelMessageReplyPipeline ?? createChannelMessageReplyPipeline
|
|
)({
|
|
cfg,
|
|
agentId: route.agentId,
|
|
channel: "telegram",
|
|
accountId: route.accountId,
|
|
typing: {
|
|
start: sendTyping,
|
|
onStartError: (err) => {
|
|
logTypingFailure({
|
|
log: logVerbose,
|
|
channel: "telegram",
|
|
target: String(chatId),
|
|
error: err,
|
|
});
|
|
},
|
|
},
|
|
});
|
|
|
|
try {
|
|
const turnResult = await runChannelInboundEvent({
|
|
channel: "telegram",
|
|
accountId: route.accountId,
|
|
raw: dispatchContext,
|
|
adapter: {
|
|
ingest: () => ({
|
|
id: ctxPayload.MessageSid ?? `${chatId}:${Date.now()}`,
|
|
timestamp: typeof ctxPayload.Timestamp === "number" ? ctxPayload.Timestamp : undefined,
|
|
rawText: ctxPayload.RawBody ?? "",
|
|
textForAgent: ctxPayload.BodyForAgent,
|
|
textForCommands: ctxPayload.CommandBody,
|
|
raw: dispatchContext,
|
|
}),
|
|
resolveTurn: () => ({
|
|
channel: "telegram",
|
|
accountId: route.accountId,
|
|
routeSessionKey: route.sessionKey,
|
|
storePath: dispatchContext.turn.storePath,
|
|
ctxPayload,
|
|
recordInboundSession: dispatchContext.turn.recordInboundSession,
|
|
record: dispatchContext.turn.record,
|
|
runDispatch: () => {
|
|
const sentBlockMediaUrls = new Set<string>();
|
|
|
|
return telegramDeps.dispatchReplyWithBufferedBlockDispatcher({
|
|
ctx: ctxPayload,
|
|
cfg,
|
|
dispatcherOptions: {
|
|
...replyPipeline,
|
|
beforeDeliver: async (payload) => payload,
|
|
onBeforeDeliverCancelled: (payload, info) => {
|
|
if (info.kind === "block") {
|
|
return enqueueDraftLaneEvent(async () => {
|
|
dropQueuedAnswerBlockRotation(payload, info.assistantMessageIndex);
|
|
});
|
|
}
|
|
return undefined;
|
|
},
|
|
deliver: async (payload, info) => {
|
|
if (isDispatchSuperseded()) {
|
|
return;
|
|
}
|
|
|
|
const normalizedPayload = normalizeDeliveryPayload(payload);
|
|
if (!normalizedPayload) {
|
|
return;
|
|
}
|
|
const deduped =
|
|
info.kind === "final"
|
|
? deduplicateBlockSentMedia(normalizedPayload, sentBlockMediaUrls)
|
|
: normalizedPayload;
|
|
if (deduped === undefined) {
|
|
return;
|
|
}
|
|
const effectivePayload = deduped;
|
|
|
|
if (
|
|
shouldSuppressLocalTelegramExecApprovalPrompt({
|
|
cfg,
|
|
accountId: route.accountId,
|
|
payload: effectivePayload,
|
|
})
|
|
) {
|
|
queuedFinal = true;
|
|
return;
|
|
}
|
|
const telegramButtons = resolvePayloadTelegramInlineButtons(effectivePayload);
|
|
const lanePayload =
|
|
info.kind === "block" &&
|
|
typeof payload.text === "string" &&
|
|
typeof effectivePayload.text === "string" &&
|
|
payload.text !== effectivePayload.text &&
|
|
payload.text.trimEnd() === effectivePayload.text &&
|
|
!effectivePayload.mediaUrl &&
|
|
!effectivePayload.mediaUrls?.length
|
|
? { ...effectivePayload, text: payload.text }
|
|
: effectivePayload;
|
|
const split = splitTextIntoLaneSegments(
|
|
{ text: lanePayload.text },
|
|
payload.isReasoning,
|
|
);
|
|
const segments = split.segments;
|
|
const reply = resolveSendableOutboundReplyParts(effectivePayload);
|
|
if (info.kind === "final" && (reply.text.length > 0 || reply.hasMedia)) {
|
|
markProgressFinalStarted();
|
|
}
|
|
if (info.kind === "final") {
|
|
await enqueueDraftLaneEvent(async () => {});
|
|
}
|
|
// Hide handled post-answer probe failures while preserving final warnings.
|
|
// Agents may intentionally run searches/commands with no result, recover,
|
|
// and send a final answer; late text-only failures are non-actionable noise.
|
|
const isToolPayloadAfterFinal =
|
|
info.kind === "tool" && (finalAnswerDeliveryStarted || finalAnswerDelivered);
|
|
const isNonTerminalWarningAfterDeliveredFinal =
|
|
isReplyPayloadNonTerminalToolErrorWarning(payload) && finalAnswerDelivered;
|
|
if (
|
|
(isToolPayloadAfterFinal || isNonTerminalWarningAfterDeliveredFinal) &&
|
|
!reply.hasMedia &&
|
|
!hasExecApprovalPayload(effectivePayload)
|
|
) {
|
|
return;
|
|
}
|
|
if (payload.isError === true) {
|
|
hadErrorReplyFailureOrSkip = true;
|
|
}
|
|
|
|
const deliverFinalAnswerText = async (
|
|
answerPayload: ReplyPayload,
|
|
text: string,
|
|
buttons?: TelegramInlineButtons,
|
|
) => {
|
|
const finalText = await resolveTranscriptBackedFinalText(text);
|
|
const deliverPostFinalFollowUpText = async () => {
|
|
await prepareAnswerLaneForText();
|
|
return deliverLaneText({
|
|
laneName: "answer",
|
|
text: finalText,
|
|
payload: answerPayload,
|
|
infoKind: "final",
|
|
buttons,
|
|
});
|
|
};
|
|
if (finalAnswerDelivered) {
|
|
return deliverPostFinalFollowUpText();
|
|
}
|
|
if (streamMode === "progress") {
|
|
return deliverProgressModeFinalAnswer(answerPayload, finalText);
|
|
}
|
|
if (!(await rotateAnswerLaneAfterToolProgress())) {
|
|
await rotateAnswerLaneAfterQueuedBlocksSettle();
|
|
}
|
|
const result = await deliverLaneText({
|
|
laneName: "answer",
|
|
text: finalText,
|
|
payload: answerPayload,
|
|
infoKind: "final",
|
|
buttons,
|
|
});
|
|
if (result.kind !== "skipped") {
|
|
markProgressFinalDelivered();
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const flushBufferedFinalAnswer = async () => {
|
|
const buffered =
|
|
reasoningStepState.takeBufferedFinalAnswer(replyFenceGeneration);
|
|
if (!buffered) {
|
|
return;
|
|
}
|
|
const bufferedButtons = resolvePayloadTelegramInlineButtons(buffered.payload);
|
|
await deliverFinalAnswerText(
|
|
buffered.payload,
|
|
buffered.text,
|
|
bufferedButtons,
|
|
);
|
|
reasoningStepState.resetForNextStep();
|
|
};
|
|
|
|
let blockDelivered = false;
|
|
const hasAnswerSegment = segments.some((segment) => segment.lane === "answer");
|
|
if (info.kind === "block" && !hasAnswerSegment) {
|
|
dropQueuedAnswerBlockRotation(effectivePayload, info.assistantMessageIndex);
|
|
}
|
|
for (const segment of segments) {
|
|
if (
|
|
segment.lane === "answer" &&
|
|
info.kind === "final" &&
|
|
reasoningStepState.shouldBufferFinalAnswer()
|
|
) {
|
|
reasoningStepState.bufferFinalAnswer({
|
|
payload: effectivePayload,
|
|
text: segment.update.text,
|
|
bufferedGeneration: replyFenceGeneration,
|
|
});
|
|
continue;
|
|
}
|
|
if (segment.lane === "reasoning") {
|
|
reasoningStepState.noteReasoningHint();
|
|
}
|
|
if (segment.lane === "answer" && info.kind === "tool") {
|
|
if (verboseProgressActive()) {
|
|
// Durable lane owns tool payloads: send standalone instead
|
|
// of diverting into the draft, which is discarded at final.
|
|
if (
|
|
await sendPayload(
|
|
applyTextToPayload(effectivePayload, segment.update.text),
|
|
)
|
|
) {
|
|
blockDelivered = true;
|
|
}
|
|
continue;
|
|
}
|
|
if (streamMode === "progress" && answerLane.stream) {
|
|
// Progress-mode streams render tool status in the
|
|
// live draft. Do not also emit text-only tool output
|
|
// as answer text, or simple commands duplicate and
|
|
// restart the progress draft.
|
|
continue;
|
|
}
|
|
await prepareAnswerLaneForToolProgress();
|
|
}
|
|
|
|
const ownedByQueuedAnswerBlockRotation = queuedAnswerBlockRotations.some(
|
|
(entry) =>
|
|
queuedAnswerBlockRotationMatchesDelivery(
|
|
entry,
|
|
lanePayload,
|
|
info.assistantMessageIndex,
|
|
),
|
|
);
|
|
|
|
const skipTextOnlyBlock =
|
|
streamMode === "partial" &&
|
|
info.kind === "block" &&
|
|
segment.lane === "answer" &&
|
|
!reply.hasMedia &&
|
|
!hasExecApprovalPayload(effectivePayload) &&
|
|
telegramButtons === undefined &&
|
|
answerLane.hasStreamedMessage &&
|
|
!activeAnswerDraftIsToolProgressOnly &&
|
|
!ownedByQueuedAnswerBlockRotation &&
|
|
segment.update.text.trimEnd() === answerLane.lastPartialText.trimEnd();
|
|
|
|
if (skipTextOnlyBlock) {
|
|
// Keep duplicate blocks available for later rotation/finalization.
|
|
skippedDuplicateAnswerBlockDraftDelivery = true;
|
|
lastAnswerBlockPayload = effectivePayload;
|
|
lastAnswerBlockText = segment.update.text;
|
|
lastAnswerBlockButtons = telegramButtons;
|
|
resetAnswerToolProgressDraft();
|
|
resetProgressDraftState();
|
|
blockDelivered = true;
|
|
continue;
|
|
}
|
|
|
|
if (segment.lane === "answer" && info.kind === "block") {
|
|
const preparedAnswerLane = await prepareAnswerLaneForText();
|
|
const shouldRotateQueuedBlock = takeQueuedAnswerBlockRotation(
|
|
lanePayload,
|
|
info.assistantMessageIndex,
|
|
);
|
|
if (shouldRotateQueuedBlock && !preparedAnswerLane) {
|
|
await rotateAnswerLaneForNewMessage();
|
|
rotateAnswerLaneWhenQueuedBlocksSettle = false;
|
|
}
|
|
resetAnswerToolProgressDraft();
|
|
resetProgressDraftState();
|
|
}
|
|
const result =
|
|
segment.lane === "answer" && info.kind === "final"
|
|
? await deliverFinalAnswerText(
|
|
effectivePayload,
|
|
segment.update.text,
|
|
telegramButtons,
|
|
)
|
|
: await deliverLaneText({
|
|
laneName: segment.lane,
|
|
text: segment.update.text,
|
|
payload: lanePayload,
|
|
infoKind: info.kind,
|
|
buttons: telegramButtons,
|
|
});
|
|
if (segment.lane === "answer" && result.kind === "preview-finalized") {
|
|
await emitPreviewFinalizedHook(result);
|
|
}
|
|
if (
|
|
segment.lane === "answer" &&
|
|
info.kind === "block" &&
|
|
(result.kind === "preview-updated" ||
|
|
result.kind === "preview-finalized" ||
|
|
result.kind === "preview-retained")
|
|
) {
|
|
lastAnswerBlockPayload = lanePayload;
|
|
lastAnswerBlockText = segment.update.text;
|
|
lastAnswerBlockButtons = telegramButtons;
|
|
}
|
|
blockDelivered = blockDelivered || result.kind !== "skipped";
|
|
if (segment.lane === "reasoning") {
|
|
if (result.kind !== "skipped") {
|
|
reasoningStepState.noteReasoningDelivered();
|
|
await flushBufferedFinalAnswer();
|
|
}
|
|
continue;
|
|
}
|
|
if (info.kind === "final") {
|
|
reasoningStepState.resetForNextStep();
|
|
}
|
|
}
|
|
const trackBlockMedia = (delivered: boolean) => {
|
|
if (
|
|
delivered &&
|
|
info.kind === "block" &&
|
|
effectivePayload.mediaUrls?.length
|
|
) {
|
|
for (const url of effectivePayload.mediaUrls) {
|
|
sentBlockMediaUrls.add(url);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (segments.length > 0) {
|
|
trackBlockMedia(blockDelivered);
|
|
return;
|
|
}
|
|
if (split.suppressedReasoningOnly) {
|
|
let delivered = false;
|
|
if (reply.hasMedia) {
|
|
const payloadWithoutSuppressedReasoning =
|
|
typeof effectivePayload.text === "string"
|
|
? { ...effectivePayload, text: "" }
|
|
: effectivePayload;
|
|
delivered = await sendPayload(payloadWithoutSuppressedReasoning, {
|
|
durable: info.kind === "final",
|
|
});
|
|
}
|
|
if (info.kind === "final" && delivered) {
|
|
markProgressFinalDelivered();
|
|
}
|
|
if (info.kind === "final") {
|
|
await flushBufferedFinalAnswer();
|
|
}
|
|
trackBlockMedia(delivered);
|
|
return;
|
|
}
|
|
|
|
if (info.kind === "final") {
|
|
await rotateAnswerLaneAfterToolProgress();
|
|
await answerLane.stream?.stop();
|
|
await reasoningLane.stream?.stop();
|
|
reasoningStepState.resetForNextStep();
|
|
}
|
|
const canSendAsIs = reply.hasMedia || reply.text.length > 0;
|
|
if (!canSendAsIs) {
|
|
if (info.kind === "final") {
|
|
await flushBufferedFinalAnswer();
|
|
}
|
|
return;
|
|
}
|
|
const delivered = await sendPayload(effectivePayload, {
|
|
durable: info.kind === "final",
|
|
});
|
|
if (info.kind === "final" && delivered) {
|
|
markProgressFinalDelivered();
|
|
}
|
|
if (info.kind === "final") {
|
|
await flushBufferedFinalAnswer();
|
|
}
|
|
trackBlockMedia(delivered);
|
|
},
|
|
onSkip: (payload, info) => {
|
|
if (info.kind === "block") {
|
|
void enqueueDraftLaneEvent(async () => {
|
|
dropQueuedAnswerBlockRotation(payload, info.assistantMessageIndex);
|
|
});
|
|
}
|
|
if (payload.isError === true) {
|
|
hadErrorReplyFailureOrSkip = true;
|
|
}
|
|
if (info.reason !== "silent") {
|
|
deliveryState.markNonSilentSkip();
|
|
}
|
|
},
|
|
onError: (err, info) => {
|
|
const errorPolicy = resolveTelegramErrorPolicy({
|
|
accountConfig: telegramCfg,
|
|
groupConfig,
|
|
topicConfig,
|
|
});
|
|
if (isSilentErrorPolicy(errorPolicy.policy)) {
|
|
return;
|
|
}
|
|
if (
|
|
errorPolicy.policy === "once" &&
|
|
shouldSuppressTelegramError({
|
|
scopeKey: buildTelegramErrorScopeKey({
|
|
accountId: route.accountId,
|
|
chatId,
|
|
threadId: threadSpec.id,
|
|
}),
|
|
cooldownMs: errorPolicy.cooldownMs,
|
|
errorMessage: String(err),
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
deliveryState.markNonSilentFailure();
|
|
runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`));
|
|
},
|
|
},
|
|
replyOptions: {
|
|
skillFilter,
|
|
disableBlockStreaming,
|
|
abortSignal: replyAbortController.signal,
|
|
sourceReplyDeliveryMode: isRoomEvent ? "message_tool_only" : undefined,
|
|
queuedDeliveryCorrelations: isRoomEvent
|
|
? [{ begin: beginDeliveryCorrelation }]
|
|
: undefined,
|
|
queuedFollowupLifecycle: isRoomEvent
|
|
? {
|
|
onEnqueued: () => {
|
|
replyAbortControllerQueued = true;
|
|
},
|
|
onComplete: () => {
|
|
replyAbortControllerQueued = false;
|
|
releaseTelegramReplyFenceAbortController(
|
|
activeReplyFenceKey,
|
|
replyAbortController,
|
|
);
|
|
},
|
|
}
|
|
: undefined,
|
|
suppressTyping: isRoomEvent,
|
|
onPartialReply:
|
|
answerLane.stream || reasoningLane.stream
|
|
? (payload) =>
|
|
enqueueDraftLaneEvent(async () => {
|
|
await ingestDraftLaneSegments(payload);
|
|
})
|
|
: undefined,
|
|
onBlockReplyQueued: answerLane.stream
|
|
? (payload, blockContext) =>
|
|
enqueueDraftLaneEvent(async () => {
|
|
await prepareQueuedAnswerBlock(payload, blockContext);
|
|
})
|
|
: undefined,
|
|
onReasoningStream: reasoningLane.stream
|
|
? (payload) =>
|
|
enqueueDraftLaneEvent(async () => {
|
|
if (splitReasoningOnNextStream) {
|
|
reasoningLane.stream?.forceNewMessage();
|
|
resetDraftLaneState(reasoningLane);
|
|
splitReasoningOnNextStream = false;
|
|
}
|
|
await ingestDraftLaneSegments(payload, true);
|
|
})
|
|
: streamReasoningInProgressDraft
|
|
? (payload) =>
|
|
enqueueDraftLaneEvent(async () => {
|
|
await pushStreamReasoningProgress(payload);
|
|
})
|
|
: undefined,
|
|
onAssistantMessageStart: answerLane.stream
|
|
? () =>
|
|
enqueueDraftLaneEvent(async () => {
|
|
reasoningStepState.resetForNextStep();
|
|
finalAnswerDelivered = false;
|
|
if (streamMode !== "progress") {
|
|
resetProgressDraftState();
|
|
}
|
|
if (answerLane.finalized) {
|
|
await rotateLaneForNewMessage(answerLane);
|
|
rotateAnswerLaneWhenQueuedBlocksSettle = false;
|
|
} else if (
|
|
answerLane.hasStreamedMessage &&
|
|
!activeAnswerDraftIsToolProgressOnly
|
|
) {
|
|
rotateAnswerLaneWhenQueuedBlocksSettle = true;
|
|
}
|
|
})
|
|
: undefined,
|
|
onReasoningEnd: reasoningLane.stream
|
|
? () =>
|
|
enqueueDraftLaneEvent(async () => {
|
|
splitReasoningOnNextStream = reasoningLane.hasStreamedMessage;
|
|
resetProgressDraftState();
|
|
})
|
|
: undefined,
|
|
suppressDefaultToolProgressMessages:
|
|
!streamDeliveryEnabled || Boolean(answerLane.stream),
|
|
allowProgressCallbacksWhenSourceDeliverySuppressed:
|
|
!isRoomEvent && Boolean(answerLane.stream),
|
|
onVerboseProgressVisibility: (isActive) => {
|
|
verboseProgressActive = isActive;
|
|
},
|
|
commentaryProgressEnabled:
|
|
streamMode === "progress" ? progressDraft.commentaryProgressEnabled : undefined,
|
|
onToolStart: async (payload) => {
|
|
const toolName = payload.name?.trim();
|
|
const progressPromise = pushStreamToolProgress(
|
|
buildChannelProgressDraftLineForEntry(
|
|
telegramCfg,
|
|
{
|
|
event: "tool",
|
|
itemId: payload.itemId,
|
|
toolCallId: payload.toolCallId,
|
|
name: toolName,
|
|
phase: payload.phase,
|
|
args: payload.args,
|
|
},
|
|
payload.detailMode ? { detailMode: payload.detailMode } : undefined,
|
|
),
|
|
{ toolName, startImmediately: true },
|
|
);
|
|
if (statusReactionController && toolName) {
|
|
await statusReactionController.setTool(toolName);
|
|
}
|
|
await progressPromise;
|
|
},
|
|
onItemEvent: async (payload) => {
|
|
if (payload.kind === "preamble") {
|
|
if (verboseProgressActive()) {
|
|
return;
|
|
}
|
|
await progressDraft.pushCommentaryProgress(payload.progressText, {
|
|
itemId: payload.itemId,
|
|
});
|
|
return;
|
|
}
|
|
await pushStreamToolProgress(
|
|
buildChannelProgressDraftLineForEntry(telegramCfg, {
|
|
event: "item",
|
|
itemId: payload.itemId,
|
|
toolCallId: payload.toolCallId,
|
|
itemKind: payload.kind,
|
|
title: payload.title,
|
|
name: payload.name,
|
|
phase: payload.phase,
|
|
status: payload.status,
|
|
summary: payload.summary,
|
|
progressText: payload.progressText,
|
|
meta: payload.meta,
|
|
}),
|
|
);
|
|
},
|
|
onPlanUpdate: async (payload) => {
|
|
if (payload.phase !== "update") {
|
|
return;
|
|
}
|
|
await pushStreamToolProgress(
|
|
buildChannelProgressDraftLine({
|
|
event: "plan",
|
|
phase: payload.phase,
|
|
title: payload.title,
|
|
explanation: payload.explanation,
|
|
steps: payload.steps,
|
|
}),
|
|
);
|
|
},
|
|
onApprovalEvent: async (payload) => {
|
|
if (payload.phase !== "requested") {
|
|
return;
|
|
}
|
|
await pushStreamToolProgress(
|
|
buildChannelProgressDraftLine({
|
|
event: "approval",
|
|
phase: payload.phase,
|
|
title: payload.title,
|
|
command: payload.command,
|
|
reason: payload.reason,
|
|
message: payload.message,
|
|
}),
|
|
);
|
|
},
|
|
onCommandOutput: async (payload) => {
|
|
if (payload.phase !== "end") {
|
|
return;
|
|
}
|
|
await pushStreamToolProgress(
|
|
buildChannelProgressDraftLineForEntry(telegramCfg, {
|
|
event: "command-output",
|
|
itemId: payload.itemId,
|
|
toolCallId: payload.toolCallId,
|
|
phase: payload.phase,
|
|
title: payload.title,
|
|
name: payload.name,
|
|
status: payload.status,
|
|
exitCode: payload.exitCode,
|
|
}),
|
|
);
|
|
},
|
|
onPatchSummary: async (payload) => {
|
|
if (payload.phase !== "end") {
|
|
return;
|
|
}
|
|
await pushStreamToolProgress(
|
|
buildChannelProgressDraftLine({
|
|
event: "patch",
|
|
itemId: payload.itemId,
|
|
toolCallId: payload.toolCallId,
|
|
phase: payload.phase,
|
|
title: payload.title,
|
|
name: payload.name,
|
|
added: payload.added,
|
|
modified: payload.modified,
|
|
deleted: payload.deleted,
|
|
summary: payload.summary,
|
|
}),
|
|
);
|
|
},
|
|
onCompactionStart: statusReactionController
|
|
? async () => {
|
|
await statusReactionController.setCompacting();
|
|
}
|
|
: undefined,
|
|
onCompactionEnd: statusReactionController
|
|
? async () => {
|
|
statusReactionController.cancelPending();
|
|
await statusReactionController.setThinking();
|
|
}
|
|
: undefined,
|
|
onModelSelected,
|
|
},
|
|
});
|
|
},
|
|
}),
|
|
},
|
|
});
|
|
if (!turnResult.dispatched) {
|
|
return { kind: "completed" };
|
|
}
|
|
({ queuedFinal } = turnResult.dispatchResult);
|
|
suppressSilentReplyFallback =
|
|
turnResult.dispatchResult.sourceReplyDeliveryMode === "message_tool_only";
|
|
} catch (err) {
|
|
dispatchError = err;
|
|
runtime.error?.(danger(`telegram dispatch failed: ${String(err)}`));
|
|
} finally {
|
|
progressDraft.cancel();
|
|
await draftLaneEventQueue;
|
|
await finalizeSkippedDuplicateAnswerBlockDraft();
|
|
const lanesToCleanup: Array<{ laneName: LaneName; lane: DraftLaneState }> = [
|
|
{ laneName: "answer", lane: answerLane },
|
|
{ laneName: "reasoning", lane: reasoningLane },
|
|
];
|
|
for (const { lane } of lanesToCleanup) {
|
|
const stream = lane.stream;
|
|
if (!stream) {
|
|
continue;
|
|
}
|
|
if (isDispatchSuperseded()) {
|
|
await (typeof stream.discard === "function" ? stream.discard() : stream.stop());
|
|
continue;
|
|
}
|
|
if (lane.finalized) {
|
|
await stream.stop();
|
|
} else {
|
|
await stream.clear();
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
dispatchWasSuperseded = isDispatchSuperseded();
|
|
releaseReplyFence();
|
|
endTelegramInboundEventDeliveryCorrelation();
|
|
}
|
|
if (dispatchWasSuperseded) {
|
|
if (statusReactionController) {
|
|
void finalizeTelegramStatusReaction({ outcome: "done", hasFinalResponse: true }).catch(
|
|
(err: unknown) => {
|
|
logVerbose(`telegram: status reaction finalize failed: ${String(err)}`);
|
|
},
|
|
);
|
|
} else {
|
|
removeAckReactionAfterReply({
|
|
removeAfterReply: removeAckAfterReply,
|
|
ackReactionPromise,
|
|
ackReactionValue: ackReactionPromise ? "ack" : null,
|
|
remove: () =>
|
|
(reactionApi?.(chatId, msg.message_id ?? 0, []) ?? Promise.resolve()).then(() => {}),
|
|
onError: (err) => {
|
|
if (!msg.message_id) {
|
|
return;
|
|
}
|
|
logAckFailure({
|
|
log: logVerbose,
|
|
channel: "telegram",
|
|
target: `${chatId}/${msg.message_id}`,
|
|
error: err,
|
|
});
|
|
},
|
|
});
|
|
}
|
|
if (!isRoomEvent || deliveryState.snapshot().delivered) {
|
|
clearGroupHistory();
|
|
}
|
|
return { kind: "completed" };
|
|
}
|
|
let sentFallback = false;
|
|
const deliverySummary = deliveryState.snapshot();
|
|
const shouldSendFailureFallback =
|
|
!isRoomEvent &&
|
|
!suppressFailureFallback &&
|
|
(dispatchError ||
|
|
(!deliverySummary.delivered &&
|
|
(deliverySummary.skippedNonSilent > 0 || deliverySummary.failedNonSilent > 0)));
|
|
if (shouldSendFailureFallback) {
|
|
const fallbackText = dispatchError
|
|
? "Something went wrong while processing your request. Please try again."
|
|
: EMPTY_RESPONSE_FALLBACK;
|
|
const result = await (telegramDeps.deliverReplies ?? deliverReplies)({
|
|
replies: [{ text: fallbackText }],
|
|
...deliveryBaseOptions,
|
|
silent: silentErrorReplies && (dispatchError != null || hadErrorReplyFailureOrSkip),
|
|
mediaLoader: telegramDeps.loadWebMedia,
|
|
});
|
|
sentFallback = result.delivered;
|
|
}
|
|
|
|
if (
|
|
!sentFallback &&
|
|
!dispatchError &&
|
|
!deliverySummary.delivered &&
|
|
!suppressSilentReplyFallback &&
|
|
!queuedFinal &&
|
|
isGroup
|
|
) {
|
|
const policySessionKey =
|
|
ctxPayload.CommandSource === "native"
|
|
? (ctxPayload.CommandTargetSessionKey ?? ctxPayload.SessionKey)
|
|
: ctxPayload.SessionKey;
|
|
const silentReplyFallback = projectOutboundPayloadPlanForDelivery(
|
|
createOutboundPayloadPlan([{ text: "NO_REPLY" }], {
|
|
cfg,
|
|
sessionKey: policySessionKey,
|
|
surface: "telegram",
|
|
}),
|
|
);
|
|
if (silentReplyFallback.length > 0) {
|
|
const result = await (telegramDeps.deliverReplies ?? deliverReplies)({
|
|
replies: silentReplyFallback,
|
|
...deliveryBaseOptions,
|
|
silent: false,
|
|
mediaLoader: telegramDeps.loadWebMedia,
|
|
});
|
|
sentFallback = result.delivered;
|
|
}
|
|
silentReplyDispatchLogger.debug("telegram turn ended without visible final response", {
|
|
hasSessionKey: Boolean(policySessionKey),
|
|
hasChatId: chatId != null,
|
|
queuedFinal,
|
|
sentFallback,
|
|
});
|
|
}
|
|
|
|
const hasFinalResponse =
|
|
deliverySummary.delivered || sentFallback || suppressSilentReplyFallback || queuedFinal;
|
|
const deliveryFailureWithoutFinalResponse =
|
|
!deliverySummary.delivered &&
|
|
(deliverySummary.skippedNonSilent > 0 || deliverySummary.failedNonSilent > 0);
|
|
const retryableDispatchFailure =
|
|
dispatchError ??
|
|
(deliveryFailureWithoutFinalResponse
|
|
? new Error(
|
|
`Telegram reply delivery failed without a final response (failed=${deliverySummary.failedNonSilent}, skipped=${deliverySummary.skippedNonSilent})`,
|
|
)
|
|
: null);
|
|
|
|
if (statusReactionController && !hasFinalResponse) {
|
|
void finalizeTelegramStatusReaction({ outcome: "error", hasFinalResponse: false }).catch(
|
|
(err: unknown) => {
|
|
logVerbose(`telegram: status reaction error finalize failed: ${String(err)}`);
|
|
},
|
|
);
|
|
}
|
|
|
|
const shouldClearGroupHistory =
|
|
!isRoomEvent || deliverySummary.delivered || sentFallback || queuedFinal;
|
|
|
|
if (retryableDispatchFailure && retryDispatchErrors && !hasFinalResponse) {
|
|
return { kind: "failed-retryable", error: retryableDispatchFailure };
|
|
}
|
|
|
|
if (!hasFinalResponse) {
|
|
if (!shouldClearGroupHistory) {
|
|
return { kind: "completed" };
|
|
}
|
|
clearGroupHistory();
|
|
return { kind: "completed" };
|
|
}
|
|
|
|
// Fire-and-forget: auto-rename DM topic on first message.
|
|
if (isDmTopic && isFirstTurnInSession) {
|
|
const userMessage = (ctxPayload.RawBody ?? ctxPayload.Body ?? "").slice(0, 500);
|
|
if (userMessage.trim()) {
|
|
const agentDir = resolveAgentDir(cfg, route.agentId);
|
|
const directAutoTopicLabel =
|
|
!isGroup && groupConfig && "autoTopicLabel" in groupConfig
|
|
? groupConfig.autoTopicLabel
|
|
: undefined;
|
|
const accountAutoTopicLabel = telegramCfg?.autoTopicLabel;
|
|
const autoTopicConfig = resolveAutoTopicLabelConfig(
|
|
directAutoTopicLabel,
|
|
accountAutoTopicLabel,
|
|
);
|
|
if (autoTopicConfig) {
|
|
const topicThreadId = threadSpec.id!;
|
|
void (async () => {
|
|
try {
|
|
const label = await generateTopicLabel({
|
|
userMessage,
|
|
prompt: autoTopicConfig.prompt,
|
|
cfg,
|
|
agentId: route.agentId,
|
|
agentDir,
|
|
});
|
|
if (!label) {
|
|
logVerbose("auto-topic-label: LLM returned empty label");
|
|
return;
|
|
}
|
|
logVerbose(`auto-topic-label: generated label (len=${label.length})`);
|
|
await bot.api.editForumTopic(chatId, topicThreadId, { name: label });
|
|
logVerbose(`auto-topic-label: renamed topic ${chatId}/${topicThreadId}`);
|
|
} catch (err) {
|
|
logVerbose(`auto-topic-label: failed: ${formatErrorMessage(err)}`);
|
|
}
|
|
})();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (statusReactionController) {
|
|
const statusReactionOutcome = dispatchError || sentFallback ? "error" : "done";
|
|
void finalizeTelegramStatusReaction({
|
|
outcome: statusReactionOutcome,
|
|
hasFinalResponse: true,
|
|
}).catch((err: unknown) => {
|
|
logVerbose(`telegram: status reaction finalize failed: ${String(err)}`);
|
|
});
|
|
} else {
|
|
removeAckReactionAfterReply({
|
|
removeAfterReply: removeAckAfterReply,
|
|
ackReactionPromise,
|
|
ackReactionValue: ackReactionPromise ? "ack" : null,
|
|
remove: () =>
|
|
(reactionApi?.(chatId, msg.message_id ?? 0, []) ?? Promise.resolve()).then(() => {}),
|
|
onError: (err) => {
|
|
if (!msg.message_id) {
|
|
return;
|
|
}
|
|
logAckFailure({
|
|
log: logVerbose,
|
|
channel: "telegram",
|
|
target: `${chatId}/${msg.message_id}`,
|
|
error: err,
|
|
});
|
|
},
|
|
});
|
|
}
|
|
if (shouldClearGroupHistory) {
|
|
clearGroupHistory();
|
|
}
|
|
return { kind: "completed" };
|
|
};
|