Files
openclaw/src/telegram/lane-delivery.ts
2026-02-22 21:33:20 +01:00

287 lines
8.3 KiB
TypeScript

import type { ReplyPayload } from "../auto-reply/types.js";
import type { TelegramInlineButtons } from "./button-types.js";
import type { TelegramDraftStream } from "./draft-stream.js";
export type LaneName = "answer" | "reasoning";
export type DraftLaneState = {
stream: TelegramDraftStream | undefined;
lastPartialText: string;
hasStreamedMessage: boolean;
};
export type ArchivedPreview = {
messageId: number;
textSnapshot: string;
};
export type LaneDeliveryResult = "preview-finalized" | "preview-updated" | "sent" | "skipped";
export type LaneDeliverySnapshot = {
delivered: boolean;
skippedNonSilent: number;
failedNonSilent: number;
};
export type LaneDeliveryStateTracker = {
markDelivered: () => void;
markNonSilentSkip: () => void;
markNonSilentFailure: () => void;
snapshot: () => LaneDeliverySnapshot;
};
export function createLaneDeliveryStateTracker(): LaneDeliveryStateTracker {
const state: LaneDeliverySnapshot = {
delivered: false,
skippedNonSilent: 0,
failedNonSilent: 0,
};
return {
markDelivered: () => {
state.delivered = true;
},
markNonSilentSkip: () => {
state.skippedNonSilent += 1;
},
markNonSilentFailure: () => {
state.failedNonSilent += 1;
},
snapshot: () => ({ ...state }),
};
}
type CreateLaneTextDelivererParams = {
lanes: Record<LaneName, DraftLaneState>;
archivedAnswerPreviews: ArchivedPreview[];
finalizedPreviewByLane: Record<LaneName, boolean>;
draftMaxChars: number;
applyTextToPayload: (payload: ReplyPayload, text: string) => ReplyPayload;
sendPayload: (payload: ReplyPayload) => Promise<boolean>;
flushDraftLane: (lane: DraftLaneState) => Promise<void>;
stopDraftLane: (lane: DraftLaneState) => Promise<void>;
editPreview: (params: {
laneName: LaneName;
messageId: number;
text: string;
context: "final" | "update";
previewButtons?: TelegramInlineButtons;
}) => Promise<void>;
deletePreviewMessage: (messageId: number) => Promise<void>;
log: (message: string) => void;
markDelivered: () => void;
};
type DeliverLaneTextParams = {
laneName: LaneName;
text: string;
payload: ReplyPayload;
infoKind: string;
previewButtons?: TelegramInlineButtons;
allowPreviewUpdateForNonFinal?: boolean;
};
type TryUpdatePreviewParams = {
lane: DraftLaneState;
laneName: LaneName;
text: string;
previewButtons?: TelegramInlineButtons;
stopBeforeEdit?: boolean;
updateLaneSnapshot?: boolean;
skipRegressive: "always" | "existingOnly";
context: "final" | "update";
previewMessageId?: number;
previewTextSnapshot?: string;
};
type ConsumeArchivedAnswerPreviewParams = {
lane: DraftLaneState;
text: string;
payload: ReplyPayload;
previewButtons?: TelegramInlineButtons;
canEditViaPreview: boolean;
};
export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
const getLanePreviewText = (lane: DraftLaneState) => lane.lastPartialText;
const tryUpdatePreviewForLane = async ({
lane,
laneName,
text,
previewButtons,
stopBeforeEdit = false,
updateLaneSnapshot = false,
skipRegressive,
context,
previewMessageId: previewMessageIdOverride,
previewTextSnapshot,
}: TryUpdatePreviewParams): Promise<boolean> => {
if (!lane.stream) {
return false;
}
const lanePreviewMessageId = lane.stream.messageId();
const hadPreviewMessage =
typeof previewMessageIdOverride === "number" || typeof lanePreviewMessageId === "number";
if (stopBeforeEdit) {
await params.stopDraftLane(lane);
}
const previewMessageId =
typeof previewMessageIdOverride === "number"
? previewMessageIdOverride
: lane.stream.messageId();
if (typeof previewMessageId !== "number") {
return false;
}
const currentPreviewText = previewTextSnapshot ?? getLanePreviewText(lane);
const shouldSkipRegressive =
Boolean(currentPreviewText) &&
currentPreviewText.startsWith(text) &&
text.length < currentPreviewText.length &&
(skipRegressive === "always" || hadPreviewMessage);
if (shouldSkipRegressive) {
params.markDelivered();
return true;
}
try {
await params.editPreview({
laneName,
messageId: previewMessageId,
text,
previewButtons,
context,
});
if (updateLaneSnapshot) {
lane.lastPartialText = text;
}
params.markDelivered();
return true;
} catch (err) {
params.log(
`telegram: ${laneName} preview ${context} edit failed; falling back to standard send (${String(err)})`,
);
return false;
}
};
const consumeArchivedAnswerPreviewForFinal = async ({
lane,
text,
payload,
previewButtons,
canEditViaPreview,
}: ConsumeArchivedAnswerPreviewParams): Promise<LaneDeliveryResult | undefined> => {
const archivedPreview = params.archivedAnswerPreviews.shift();
if (!archivedPreview) {
return undefined;
}
if (canEditViaPreview) {
const finalized = await tryUpdatePreviewForLane({
lane,
laneName: "answer",
text,
previewButtons,
stopBeforeEdit: false,
skipRegressive: "existingOnly",
context: "final",
previewMessageId: archivedPreview.messageId,
previewTextSnapshot: archivedPreview.textSnapshot,
});
if (finalized) {
return "preview-finalized";
}
}
try {
await params.deletePreviewMessage(archivedPreview.messageId);
} catch (err) {
params.log(
`telegram: archived answer preview cleanup failed (${archivedPreview.messageId}): ${String(err)}`,
);
}
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
return delivered ? "sent" : "skipped";
};
return async ({
laneName,
text,
payload,
infoKind,
previewButtons,
allowPreviewUpdateForNonFinal = false,
}: DeliverLaneTextParams): Promise<LaneDeliveryResult> => {
const lane = params.lanes[laneName];
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
const canEditViaPreview =
!hasMedia && text.length > 0 && text.length <= params.draftMaxChars && !payload.isError;
if (infoKind === "final") {
if (laneName === "answer") {
const archivedResult = await consumeArchivedAnswerPreviewForFinal({
lane,
text,
payload,
previewButtons,
canEditViaPreview,
});
if (archivedResult) {
return archivedResult;
}
}
if (canEditViaPreview && !params.finalizedPreviewByLane[laneName]) {
await params.flushDraftLane(lane);
if (laneName === "answer") {
const archivedResultAfterFlush = await consumeArchivedAnswerPreviewForFinal({
lane,
text,
payload,
previewButtons,
canEditViaPreview,
});
if (archivedResultAfterFlush) {
return archivedResultAfterFlush;
}
}
const finalized = await tryUpdatePreviewForLane({
lane,
laneName,
text,
previewButtons,
stopBeforeEdit: true,
skipRegressive: "existingOnly",
context: "final",
});
if (finalized) {
params.finalizedPreviewByLane[laneName] = true;
return "preview-finalized";
}
} else if (!hasMedia && !payload.isError && text.length > params.draftMaxChars) {
params.log(
`telegram: preview final too long for edit (${text.length} > ${params.draftMaxChars}); falling back to standard send`,
);
}
await params.stopDraftLane(lane);
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
return delivered ? "sent" : "skipped";
}
if (allowPreviewUpdateForNonFinal && canEditViaPreview) {
const updated = await tryUpdatePreviewForLane({
lane,
laneName,
text,
previewButtons,
stopBeforeEdit: false,
updateLaneSnapshot: true,
skipRegressive: "always",
context: "update",
});
if (updated) {
return "preview-updated";
}
}
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
return delivered ? "sent" : "skipped";
};
}