Files
openclaw/extensions/telegram/src/lane-delivery-text-deliverer.ts
scoootscooob e5bca0832f refactor: move Telegram channel implementation to extensions/ (#45635)
* refactor: move Telegram channel implementation to extensions/telegram/src/

Move all Telegram channel code (123 files + 10 bot/ files + 8 channel plugin
files) from src/telegram/ and src/channels/plugins/*/telegram.ts to
extensions/telegram/src/. Leave thin re-export shims at original locations so
cross-cutting src/ imports continue to resolve.

- Fix all relative import paths in moved files (../X/ -> ../../../src/X/)
- Fix vi.mock paths in 60 test files
- Fix inline typeof import() expressions
- Update tsconfig.plugin-sdk.dts.json rootDir to "." for cross-directory DTS
- Update write-plugin-sdk-entry-dts.ts for new rootDir structure
- Move channel plugin files with correct path remapping

* fix: support keyed telegram send deps

* fix: sync telegram extension copies with latest main

* fix: correct import paths and remove misplaced files in telegram extension

* fix: sync outbound-adapter with main (add sendTelegramPayloadMessages) and fix delivery.test import path
2026-03-14 02:50:17 -07:00

575 lines
19 KiB
TypeScript

import type { ReplyPayload } from "../../../src/auto-reply/types.js";
import type { TelegramInlineButtons } from "./button-types.js";
import type { TelegramDraftStream } from "./draft-stream.js";
import {
isRecoverableTelegramNetworkError,
isSafeToRetrySendError,
isTelegramClientRejection,
} from "./network-errors.js";
const MESSAGE_NOT_MODIFIED_RE =
/400:\s*Bad Request:\s*message is not modified|MESSAGE_NOT_MODIFIED/i;
const MESSAGE_NOT_FOUND_RE =
/400:\s*Bad Request:\s*message to edit not found|MESSAGE_ID_INVALID|message can't be edited/i;
function extractErrorText(err: unknown): string {
return typeof err === "string"
? err
: err instanceof Error
? err.message
: typeof err === "object" && err && "description" in err
? typeof err.description === "string"
? err.description
: ""
: "";
}
function isMessageNotModifiedError(err: unknown): boolean {
return MESSAGE_NOT_MODIFIED_RE.test(extractErrorText(err));
}
/**
* Returns true when Telegram rejects an edit because the target message can no
* longer be resolved or edited. The caller still needs preview context to
* decide whether to retain a different visible preview or fall back to send.
*/
function isMissingPreviewMessageError(err: unknown): boolean {
return MESSAGE_NOT_FOUND_RE.test(extractErrorText(err));
}
export type LaneName = "answer" | "reasoning";
export type DraftLaneState = {
stream: TelegramDraftStream | undefined;
lastPartialText: string;
hasStreamedMessage: boolean;
};
export type ArchivedPreview = {
messageId: number;
textSnapshot: string;
// Boundary-finalized previews should remain visible even if no matching
// final edit arrives; superseded previews can be safely deleted.
deleteIfUnused?: boolean;
};
export type LanePreviewLifecycle = "transient" | "complete";
export type LaneDeliveryResult =
| "preview-finalized"
| "preview-retained"
| "preview-updated"
| "sent"
| "skipped";
type CreateLaneTextDelivererParams = {
lanes: Record<LaneName, DraftLaneState>;
archivedAnswerPreviews: ArchivedPreview[];
activePreviewLifecycleByLane: Record<LaneName, LanePreviewLifecycle>;
retainPreviewOnCleanupByLane: 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 PreviewEditResult = "edited" | "retained" | "fallback";
type ConsumeArchivedAnswerPreviewParams = {
lane: DraftLaneState;
text: string;
payload: ReplyPayload;
previewButtons?: TelegramInlineButtons;
canEditViaPreview: boolean;
};
type PreviewUpdateContext = "final" | "update";
type RegressiveSkipMode = "always" | "existingOnly";
type ResolvePreviewTargetParams = {
lane: DraftLaneState;
previewMessageIdOverride?: number;
stopBeforeEdit: boolean;
context: PreviewUpdateContext;
};
type PreviewTargetResolution = {
hadPreviewMessage: boolean;
previewMessageId: number | undefined;
stopCreatesFirstPreview: boolean;
};
function shouldSkipRegressivePreviewUpdate(args: {
currentPreviewText: string | undefined;
text: string;
skipRegressive: RegressiveSkipMode;
hadPreviewMessage: boolean;
}): boolean {
const currentPreviewText = args.currentPreviewText;
if (currentPreviewText === undefined) {
return false;
}
return (
currentPreviewText.startsWith(args.text) &&
args.text.length < currentPreviewText.length &&
(args.skipRegressive === "always" || args.hadPreviewMessage)
);
}
function resolvePreviewTarget(params: ResolvePreviewTargetParams): PreviewTargetResolution {
const lanePreviewMessageId = params.lane.stream?.messageId();
const previewMessageId =
typeof params.previewMessageIdOverride === "number"
? params.previewMessageIdOverride
: lanePreviewMessageId;
const hadPreviewMessage =
typeof params.previewMessageIdOverride === "number" || typeof lanePreviewMessageId === "number";
return {
hadPreviewMessage,
previewMessageId: typeof previewMessageId === "number" ? previewMessageId : undefined,
stopCreatesFirstPreview:
params.stopBeforeEdit && !hadPreviewMessage && params.context === "final",
};
}
export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
const getLanePreviewText = (lane: DraftLaneState) => lane.lastPartialText;
const markActivePreviewComplete = (laneName: LaneName) => {
params.activePreviewLifecycleByLane[laneName] = "complete";
params.retainPreviewOnCleanupByLane[laneName] = true;
};
const isDraftPreviewLane = (lane: DraftLaneState) => lane.stream?.previewMode?.() === "draft";
const canMaterializeDraftFinal = (
lane: DraftLaneState,
previewButtons?: TelegramInlineButtons,
) => {
const hasPreviewButtons = Boolean(previewButtons && previewButtons.length > 0);
return (
isDraftPreviewLane(lane) &&
!hasPreviewButtons &&
typeof lane.stream?.materialize === "function"
);
};
const tryMaterializeDraftPreviewForFinal = async (args: {
lane: DraftLaneState;
laneName: LaneName;
text: string;
}): Promise<boolean> => {
const stream = args.lane.stream;
if (!stream || !isDraftPreviewLane(args.lane)) {
return false;
}
// Draft previews have no message_id to edit; materialize the final text
// into a real message and treat that as the finalized delivery.
stream.update(args.text);
const materializedMessageId = await stream.materialize?.();
if (typeof materializedMessageId !== "number") {
params.log(
`telegram: ${args.laneName} draft preview materialize produced no message id; falling back to standard send`,
);
return false;
}
args.lane.lastPartialText = args.text;
params.markDelivered();
return true;
};
const tryEditPreviewMessage = async (args: {
laneName: LaneName;
messageId: number;
text: string;
context: "final" | "update";
previewButtons?: TelegramInlineButtons;
updateLaneSnapshot: boolean;
lane: DraftLaneState;
finalTextAlreadyLanded: boolean;
retainAlternatePreviewOnMissingTarget: boolean;
}): Promise<PreviewEditResult> => {
try {
await params.editPreview({
laneName: args.laneName,
messageId: args.messageId,
text: args.text,
previewButtons: args.previewButtons,
context: args.context,
});
if (args.updateLaneSnapshot) {
args.lane.lastPartialText = args.text;
}
params.markDelivered();
return "edited";
} catch (err) {
if (isMessageNotModifiedError(err)) {
params.log(
`telegram: ${args.laneName} preview ${args.context} edit returned "message is not modified"; treating as delivered`,
);
params.markDelivered();
return "edited";
}
if (args.context === "final") {
if (args.finalTextAlreadyLanded) {
params.log(
`telegram: ${args.laneName} preview final edit failed after stop flush; keeping existing preview (${String(err)})`,
);
params.markDelivered();
return "retained";
}
if (isSafeToRetrySendError(err)) {
params.log(
`telegram: ${args.laneName} preview final edit failed before reaching Telegram; falling back to standard send (${String(err)})`,
);
return "fallback";
}
if (isMissingPreviewMessageError(err)) {
if (args.retainAlternatePreviewOnMissingTarget) {
params.log(
`telegram: ${args.laneName} preview final edit target missing; keeping alternate preview without fallback (${String(err)})`,
);
params.markDelivered();
return "retained";
}
params.log(
`telegram: ${args.laneName} preview final edit target missing with no alternate preview; falling back to standard send (${String(err)})`,
);
return "fallback";
}
if (isRecoverableTelegramNetworkError(err, { allowMessageMatch: true })) {
params.log(
`telegram: ${args.laneName} preview final edit may have landed despite network error; keeping existing preview (${String(err)})`,
);
params.markDelivered();
return "retained";
}
if (isTelegramClientRejection(err)) {
params.log(
`telegram: ${args.laneName} preview final edit rejected by Telegram (client error); falling back to standard send (${String(err)})`,
);
return "fallback";
}
// Default: ambiguous error — prefer incomplete over duplicate
params.log(
`telegram: ${args.laneName} preview final edit failed with ambiguous error; keeping existing preview to avoid duplicate (${String(err)})`,
);
params.markDelivered();
return "retained";
}
params.log(
`telegram: ${args.laneName} preview ${args.context} edit failed; falling back to standard send (${String(err)})`,
);
return "fallback";
}
};
const tryUpdatePreviewForLane = async ({
lane,
laneName,
text,
previewButtons,
stopBeforeEdit = false,
updateLaneSnapshot = false,
skipRegressive,
context,
previewMessageId: previewMessageIdOverride,
previewTextSnapshot,
}: TryUpdatePreviewParams): Promise<PreviewEditResult> => {
const editPreview = (
messageId: number,
finalTextAlreadyLanded: boolean,
retainAlternatePreviewOnMissingTarget: boolean,
) =>
tryEditPreviewMessage({
laneName,
messageId,
text,
context,
previewButtons,
updateLaneSnapshot,
lane,
finalTextAlreadyLanded,
retainAlternatePreviewOnMissingTarget,
});
const finalizePreview = (
previewMessageId: number,
finalTextAlreadyLanded: boolean,
hadPreviewMessage: boolean,
retainAlternatePreviewOnMissingTarget = false,
): PreviewEditResult | Promise<PreviewEditResult> => {
const currentPreviewText = previewTextSnapshot ?? getLanePreviewText(lane);
const shouldSkipRegressive = shouldSkipRegressivePreviewUpdate({
currentPreviewText,
text,
skipRegressive,
hadPreviewMessage,
});
if (shouldSkipRegressive) {
params.markDelivered();
return "edited";
}
return editPreview(
previewMessageId,
finalTextAlreadyLanded,
retainAlternatePreviewOnMissingTarget,
);
};
if (!lane.stream) {
return "fallback";
}
const previewTargetBeforeStop = resolvePreviewTarget({
lane,
previewMessageIdOverride,
stopBeforeEdit,
context,
});
if (previewTargetBeforeStop.stopCreatesFirstPreview) {
// Final stop() can create the first visible preview message.
// Prime pending text so the stop flush sends the final text snapshot.
lane.stream.update(text);
await params.stopDraftLane(lane);
const previewTargetAfterStop = resolvePreviewTarget({
lane,
stopBeforeEdit: false,
context,
});
if (typeof previewTargetAfterStop.previewMessageId !== "number") {
return "fallback";
}
return finalizePreview(previewTargetAfterStop.previewMessageId, true, false);
}
if (stopBeforeEdit) {
await params.stopDraftLane(lane);
}
const previewTargetAfterStop = resolvePreviewTarget({
lane,
previewMessageIdOverride,
stopBeforeEdit: false,
context,
});
if (typeof previewTargetAfterStop.previewMessageId !== "number") {
// Only retain for final delivery when a prior preview is already visible
// to the user — otherwise falling back is safer than silence. For updates,
// always fall back so the caller can attempt sendPayload without stale
// markDelivered() state.
if (context === "final" && lane.hasStreamedMessage && lane.stream?.sendMayHaveLanded?.()) {
params.log(
`telegram: ${laneName} preview send may have landed despite missing message id; keeping to avoid duplicate`,
);
params.markDelivered();
return "retained";
}
return "fallback";
}
const activePreviewMessageId = lane.stream?.messageId();
return finalizePreview(
previewTargetAfterStop.previewMessageId,
false,
previewTargetAfterStop.hadPreviewMessage,
typeof activePreviewMessageId === "number" &&
activePreviewMessageId !== previewTargetAfterStop.previewMessageId,
);
};
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 === "edited") {
return "preview-finalized";
}
if (finalized === "retained") {
params.retainPreviewOnCleanupByLane.answer = true;
return "preview-retained";
}
}
// Send the replacement message first, then clean up the old preview.
// This avoids the visual "disappear then reappear" flash.
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
// Once this archived preview is consumed by a fallback final send, delete it
// regardless of deleteIfUnused. That flag only applies to unconsumed boundaries.
if (delivered || archivedPreview.deleteIfUnused !== false) {
try {
await params.deletePreviewMessage(archivedPreview.messageId);
} catch (err) {
params.log(
`telegram: archived answer preview cleanup failed (${archivedPreview.messageId}): ${String(err)}`,
);
}
}
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") {
// Transient previews must decide cleanup retention per final attempt.
// Completed previews intentionally stay retained so later extra payloads
// do not clear the already-finalized message.
if (params.activePreviewLifecycleByLane[laneName] === "transient") {
params.retainPreviewOnCleanupByLane[laneName] = false;
}
if (laneName === "answer") {
const archivedResult = await consumeArchivedAnswerPreviewForFinal({
lane,
text,
payload,
previewButtons,
canEditViaPreview,
});
if (archivedResult) {
return archivedResult;
}
}
if (canEditViaPreview && params.activePreviewLifecycleByLane[laneName] === "transient") {
await params.flushDraftLane(lane);
if (laneName === "answer") {
const archivedResultAfterFlush = await consumeArchivedAnswerPreviewForFinal({
lane,
text,
payload,
previewButtons,
canEditViaPreview,
});
if (archivedResultAfterFlush) {
return archivedResultAfterFlush;
}
}
if (canMaterializeDraftFinal(lane, previewButtons)) {
const materialized = await tryMaterializeDraftPreviewForFinal({
lane,
laneName,
text,
});
if (materialized) {
markActivePreviewComplete(laneName);
return "preview-finalized";
}
}
const finalized = await tryUpdatePreviewForLane({
lane,
laneName,
text,
previewButtons,
stopBeforeEdit: true,
skipRegressive: "existingOnly",
context: "final",
});
if (finalized === "edited") {
markActivePreviewComplete(laneName);
return "preview-finalized";
}
if (finalized === "retained") {
markActivePreviewComplete(laneName);
return "preview-retained";
}
} 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) {
if (isDraftPreviewLane(lane)) {
// DM draft flow has no message_id to edit; updates are sent via sendMessageDraft.
// Only mark as updated when the draft flush actually emits an update.
const previewRevisionBeforeFlush = lane.stream?.previewRevision?.() ?? 0;
lane.stream?.update(text);
await params.flushDraftLane(lane);
const previewUpdated = (lane.stream?.previewRevision?.() ?? 0) > previewRevisionBeforeFlush;
if (!previewUpdated) {
params.log(
`telegram: ${laneName} draft preview update not emitted; falling back to standard send`,
);
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
return delivered ? "sent" : "skipped";
}
lane.lastPartialText = text;
params.markDelivered();
return "preview-updated";
}
const updated = await tryUpdatePreviewForLane({
lane,
laneName,
text,
previewButtons,
stopBeforeEdit: false,
updateLaneSnapshot: true,
skipRegressive: "always",
context: "update",
});
if (updated === "edited") {
return "preview-updated";
}
}
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
return delivered ? "sent" : "skipped";
};
}