mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 02:30:21 +00:00
* 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
575 lines
19 KiB
TypeScript
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";
|
|
};
|
|
}
|