Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
606 lines
18 KiB
TypeScript
606 lines
18 KiB
TypeScript
// Telegram plugin module implements lane delivery text deliverer behavior.
|
|
import {
|
|
createPreviewMessageReceipt,
|
|
type MessageReceipt,
|
|
} from "openclaw/plugin-sdk/channel-outbound";
|
|
import {
|
|
isPotentialTruncatedFinal,
|
|
selectLongerFinalText,
|
|
} from "openclaw/plugin-sdk/channel-outbound";
|
|
import {
|
|
buildTtsSupplementMediaPayload,
|
|
getReplyPayloadTtsSupplement,
|
|
resolveSendableOutboundReplyParts,
|
|
} from "openclaw/plugin-sdk/reply-payload";
|
|
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
|
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;
|
|
finalized: boolean;
|
|
activeChunkIndex: number;
|
|
};
|
|
|
|
type LanePreviewFinalizedDelivery = {
|
|
content: string;
|
|
promptContextContent?: string;
|
|
messageId: number;
|
|
buttonsAttached?: boolean;
|
|
receipt: MessageReceipt;
|
|
};
|
|
|
|
type LanePreviewFinalizedDeliveryInput = Omit<LanePreviewFinalizedDelivery, "receipt"> & {
|
|
receipt?: MessageReceipt;
|
|
};
|
|
|
|
export type LaneDeliveryResult =
|
|
| {
|
|
kind: "preview-finalized";
|
|
delivery: LanePreviewFinalizedDelivery;
|
|
}
|
|
| { kind: "preview-retained" | "preview-updated" | "sent" | "skipped" };
|
|
|
|
type CreateLaneTextDelivererParams = {
|
|
lanes: Record<LaneName, DraftLaneState>;
|
|
draftMaxChars: number;
|
|
applyTextToPayload: (payload: ReplyPayload, text: string) => ReplyPayload;
|
|
applyTextToFollowUpPayload?: (payload: ReplyPayload, text: string) => ReplyPayload;
|
|
splitFinalTextForStream?: (text: string) => readonly string[];
|
|
sendPayload: (
|
|
payload: ReplyPayload,
|
|
options?: { durable?: boolean; silent?: boolean },
|
|
) => Promise<boolean>;
|
|
flushDraftLane: (lane: DraftLaneState) => Promise<void>;
|
|
stopDraftLane: (lane: DraftLaneState) => Promise<void>;
|
|
clearDraftLane: (lane: DraftLaneState) => Promise<void>;
|
|
editStreamMessage: (params: {
|
|
laneName: LaneName;
|
|
messageId: number;
|
|
text: string;
|
|
buttons?: TelegramInlineButtons;
|
|
}) => Promise<void>;
|
|
resolveFinalTextCandidate?: (params: {
|
|
finalText: string;
|
|
laneName: LaneName;
|
|
}) => Promise<string | undefined> | string | undefined;
|
|
log: (message: string) => void;
|
|
markDelivered: () => void;
|
|
};
|
|
|
|
type DeliverLaneTextParams = {
|
|
laneName: LaneName;
|
|
text: string;
|
|
payload: ReplyPayload;
|
|
infoKind: string;
|
|
buttons?: TelegramInlineButtons;
|
|
finalizePreview?: boolean;
|
|
durable?: boolean;
|
|
};
|
|
|
|
function result(
|
|
kind: LaneDeliveryResult["kind"],
|
|
delivery?: LanePreviewFinalizedDeliveryInput,
|
|
): LaneDeliveryResult {
|
|
if (kind === "preview-finalized") {
|
|
const finalized = delivery!;
|
|
return {
|
|
kind,
|
|
delivery: {
|
|
...finalized,
|
|
receipt: finalized.receipt ?? createPreviewMessageReceipt({ id: finalized.messageId }),
|
|
},
|
|
};
|
|
}
|
|
return { kind };
|
|
}
|
|
|
|
function compactChunks(chunks: readonly string[]): string[] {
|
|
const out: string[] = [];
|
|
let whitespace = "";
|
|
for (const chunk of chunks) {
|
|
if (!chunk) {
|
|
continue;
|
|
}
|
|
if (chunk.trim().length === 0) {
|
|
whitespace += chunk;
|
|
continue;
|
|
}
|
|
out.push(`${whitespace}${chunk}`);
|
|
whitespace = "";
|
|
}
|
|
if (whitespace && out.length > 0) {
|
|
out[out.length - 1] = `${out[out.length - 1]}${whitespace}`;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function isDeliveredPrefix(params: { deliveredText: string | undefined; finalText: string }) {
|
|
if (!params.deliveredText || params.deliveredText.length === 0) {
|
|
return false;
|
|
}
|
|
return (
|
|
params.finalText === params.deliveredText || params.finalText.startsWith(params.deliveredText)
|
|
);
|
|
}
|
|
|
|
export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
|
const followUpPayload = (payload: ReplyPayload, text: string) =>
|
|
params.applyTextToFollowUpPayload
|
|
? params.applyTextToFollowUpPayload(payload, text)
|
|
: params.applyTextToPayload(payload, text);
|
|
const textOnlyPayload = (payload: ReplyPayload): ReplyPayload => {
|
|
const {
|
|
mediaUrl: _mediaUrl,
|
|
mediaUrls: _mediaUrls,
|
|
audioAsVoice: _audioAsVoice,
|
|
spokenText: _spokenText,
|
|
...rest
|
|
} = payload;
|
|
return rest;
|
|
};
|
|
const mediaChannelData = (
|
|
channelData: ReplyPayload["channelData"],
|
|
options?: { stripButtons?: boolean },
|
|
): ReplyPayload["channelData"] => {
|
|
if (!options?.stripButtons) {
|
|
return channelData;
|
|
}
|
|
const telegramData = channelData?.telegram;
|
|
if (!telegramData || typeof telegramData !== "object" || Array.isArray(telegramData)) {
|
|
return channelData;
|
|
}
|
|
const { buttons: _buttons, ...telegramRest } = telegramData as Record<string, unknown>;
|
|
if (_buttons === undefined) {
|
|
return channelData;
|
|
}
|
|
const next: Record<string, unknown> = { ...channelData };
|
|
if (Object.keys(telegramRest).length > 0) {
|
|
next.telegram = telegramRest;
|
|
} else {
|
|
delete next.telegram;
|
|
}
|
|
return Object.keys(next).length > 0 ? next : undefined;
|
|
};
|
|
const withMediaChannelData = (
|
|
payload: ReplyPayload,
|
|
options?: { stripButtons?: boolean },
|
|
): ReplyPayload => {
|
|
const channelData = mediaChannelData(payload.channelData, options);
|
|
if (channelData === payload.channelData) {
|
|
return payload;
|
|
}
|
|
if (channelData) {
|
|
return { ...payload, channelData };
|
|
}
|
|
const { channelData: _channelData, ...rest } = payload;
|
|
return rest;
|
|
};
|
|
const withFallbackTelegramButtons = (
|
|
payload: ReplyPayload,
|
|
buttons?: TelegramInlineButtons,
|
|
): ReplyPayload => {
|
|
if (!buttons) {
|
|
return payload;
|
|
}
|
|
const channelData = payload.channelData ?? {};
|
|
const telegramData = channelData.telegram;
|
|
if (
|
|
telegramData &&
|
|
typeof telegramData === "object" &&
|
|
!Array.isArray(telegramData) &&
|
|
"buttons" in telegramData
|
|
) {
|
|
return payload;
|
|
}
|
|
const telegramRest =
|
|
telegramData && typeof telegramData === "object" && !Array.isArray(telegramData)
|
|
? (telegramData as Record<string, unknown>)
|
|
: {};
|
|
return {
|
|
...payload,
|
|
channelData: {
|
|
...channelData,
|
|
telegram: {
|
|
...telegramRest,
|
|
buttons,
|
|
},
|
|
},
|
|
};
|
|
};
|
|
const mediaOnlyPayload = (
|
|
payload: ReplyPayload,
|
|
text: string,
|
|
options?: { stripButtons?: boolean; fallbackButtons?: TelegramInlineButtons },
|
|
): ReplyPayload => {
|
|
if (getReplyPayloadTtsSupplement(payload)) {
|
|
return withFallbackTelegramButtons(
|
|
withMediaChannelData(
|
|
buildTtsSupplementMediaPayload(params.applyTextToPayload(payload, text)),
|
|
options,
|
|
),
|
|
options?.fallbackButtons,
|
|
);
|
|
}
|
|
if (payload.audioAsVoice === true) {
|
|
const {
|
|
text: _text,
|
|
presentation: _presentation,
|
|
interactive: _interactive,
|
|
btw: _btw,
|
|
spokenText: _spokenText,
|
|
...voicePayload
|
|
} = params.applyTextToPayload(payload, text);
|
|
return withFallbackTelegramButtons(
|
|
withMediaChannelData({ ...voicePayload, spokenText: text }, options),
|
|
options?.fallbackButtons,
|
|
);
|
|
}
|
|
const {
|
|
text: _text,
|
|
presentation: _presentation,
|
|
interactive: _interactive,
|
|
btw: _btw,
|
|
...rest
|
|
} = payload;
|
|
return withFallbackTelegramButtons(
|
|
withMediaChannelData(rest, options),
|
|
options?.fallbackButtons,
|
|
);
|
|
};
|
|
|
|
const clearUnfinalizedStream = async (lane: DraftLaneState) => {
|
|
if (!lane.stream || lane.finalized) {
|
|
return;
|
|
}
|
|
await params.clearDraftLane(lane);
|
|
lane.lastPartialText = "";
|
|
lane.hasStreamedMessage = false;
|
|
};
|
|
|
|
const discardUnmaterializedStream = async (lane: DraftLaneState) => {
|
|
const stream = lane.stream;
|
|
if (stream) {
|
|
await stream.discard?.();
|
|
stream.forceNewMessage();
|
|
}
|
|
lane.lastPartialText = "";
|
|
lane.hasStreamedMessage = false;
|
|
lane.finalized = false;
|
|
};
|
|
|
|
const rotateFinalizedStream = (lane: DraftLaneState) => {
|
|
if (!lane.stream || !lane.finalized) {
|
|
return;
|
|
}
|
|
lane.stream.forceNewMessage();
|
|
lane.lastPartialText = "";
|
|
lane.hasStreamedMessage = false;
|
|
lane.finalized = false;
|
|
};
|
|
|
|
const streamText = async (
|
|
laneName: LaneName,
|
|
lane: DraftLaneState,
|
|
text: string,
|
|
payload: ReplyPayload,
|
|
useFinalTextRecovery: boolean,
|
|
finalizePreview: boolean,
|
|
buttons?: TelegramInlineButtons,
|
|
): Promise<LaneDeliveryResult | undefined> => {
|
|
const stream = lane.stream;
|
|
if (!stream || text.length === 0 || payload.isError) {
|
|
return undefined;
|
|
}
|
|
rotateFinalizedStream(lane);
|
|
|
|
const chunks =
|
|
text.length > params.draftMaxChars
|
|
? compactChunks(params.splitFinalTextForStream?.(text) ?? [])
|
|
: [text];
|
|
|
|
const clampActiveChunkIndex = () =>
|
|
Math.min(lane.activeChunkIndex, Math.max(0, chunks.length - 1));
|
|
const activeChunkIndex = clampActiveChunkIndex();
|
|
const activeChunk = chunks[activeChunkIndex];
|
|
const remainingChunks = chunks.slice(activeChunkIndex + 1);
|
|
|
|
if (!activeChunk || activeChunk.length > params.draftMaxChars) {
|
|
return undefined;
|
|
}
|
|
|
|
const activeFullText = chunks.slice(activeChunkIndex).join("");
|
|
const finalText = activeFullText.trimEnd();
|
|
const deliveredStreamTextBeforeUpdate = stream.lastDeliveredText?.();
|
|
const deliveredPrefixBeforeUpdate =
|
|
useFinalTextRecovery &&
|
|
deliveredStreamTextBeforeUpdate !== undefined &&
|
|
isDeliveredPrefix({
|
|
deliveredText: deliveredStreamTextBeforeUpdate,
|
|
finalText,
|
|
}) &&
|
|
deliveredStreamTextBeforeUpdate.length > activeChunk.trimEnd().length;
|
|
|
|
const finalizeDeliveredPrefix = async (
|
|
deliveredStreamText: string,
|
|
messageId: number,
|
|
): Promise<LaneDeliveryResult> => {
|
|
lane.finalized = true;
|
|
params.markDelivered();
|
|
let buttonsAttached = false;
|
|
if (buttons) {
|
|
const deliveredChunks = compactChunks(
|
|
params.splitFinalTextForStream?.(deliveredStreamText) ?? [],
|
|
);
|
|
const currentChunk = deliveredChunks.at(-1);
|
|
if (currentChunk && currentChunk.length <= params.draftMaxChars) {
|
|
try {
|
|
await params.editStreamMessage({ laneName, messageId, text: currentChunk, buttons });
|
|
buttonsAttached = true;
|
|
} catch (err) {
|
|
params.log(`telegram: ${laneName} stream button edit failed: ${String(err)}`);
|
|
}
|
|
}
|
|
}
|
|
const suffix = activeFullText.slice(deliveredStreamText.length);
|
|
if (suffix.trim().length > 0) {
|
|
for (const chunk of compactChunks(params.splitFinalTextForStream?.(suffix) ?? [])) {
|
|
if (chunk.trim().length === 0) {
|
|
continue;
|
|
}
|
|
await params.sendPayload(followUpPayload(payload, chunk));
|
|
}
|
|
}
|
|
return result("preview-finalized", {
|
|
content: text,
|
|
promptContextContent: deliveredStreamText,
|
|
messageId,
|
|
buttonsAttached,
|
|
});
|
|
};
|
|
|
|
const candidateTexts = [stream.lastDeliveredText?.(), lane.lastPartialText];
|
|
if (
|
|
useFinalTextRecovery &&
|
|
remainingChunks.length === 0 &&
|
|
isPotentialTruncatedFinal(activeFullText)
|
|
) {
|
|
const resolvedFullCandidate = await params.resolveFinalTextCandidate?.({
|
|
finalText: text,
|
|
laneName,
|
|
});
|
|
if (resolvedFullCandidate) {
|
|
const resolvedChunks =
|
|
resolvedFullCandidate.length > params.draftMaxChars
|
|
? compactChunks(params.splitFinalTextForStream?.(resolvedFullCandidate) ?? [])
|
|
: [resolvedFullCandidate];
|
|
candidateTexts.push(resolvedChunks.slice(activeChunkIndex).join(""));
|
|
}
|
|
}
|
|
|
|
const retainedPreview =
|
|
useFinalTextRecovery &&
|
|
remainingChunks.length === 0 &&
|
|
isPotentialTruncatedFinal(activeFullText)
|
|
? selectLongerFinalText({
|
|
finalText: activeFullText,
|
|
candidateTexts,
|
|
})
|
|
: undefined;
|
|
|
|
if (retainedPreview && (!buttons || retainedPreview.length <= params.draftMaxChars)) {
|
|
const previewText = retainedPreview;
|
|
lane.lastPartialText = previewText;
|
|
lane.hasStreamedMessage = true;
|
|
await params.stopDraftLane(lane);
|
|
const messageId = stream.messageId();
|
|
if (typeof messageId !== "number") {
|
|
if (stream.sendMayHaveLanded?.()) {
|
|
lane.finalized = true;
|
|
params.markDelivered();
|
|
return result("preview-retained");
|
|
}
|
|
return undefined;
|
|
}
|
|
const deliveredStreamTextAfterStop = stream.lastDeliveredText?.();
|
|
if (
|
|
deliveredStreamTextAfterStop !== undefined &&
|
|
deliveredStreamTextAfterStop !== previewText
|
|
) {
|
|
return undefined;
|
|
}
|
|
let buttonsAttached = false;
|
|
if (buttons) {
|
|
try {
|
|
await params.editStreamMessage({ laneName, messageId, text: previewText, buttons });
|
|
buttonsAttached = true;
|
|
} catch (err) {
|
|
params.log(`telegram: ${laneName} stream button edit failed: ${String(err)}`);
|
|
}
|
|
}
|
|
for (const chunk of remainingChunks) {
|
|
if (chunk.trim().length === 0) {
|
|
continue;
|
|
}
|
|
await params.sendPayload(followUpPayload(payload, chunk));
|
|
}
|
|
lane.finalized = true;
|
|
params.markDelivered();
|
|
return result("preview-finalized", {
|
|
content: previewText,
|
|
promptContextContent: previewText,
|
|
messageId,
|
|
buttonsAttached,
|
|
});
|
|
}
|
|
|
|
if (!deliveredPrefixBeforeUpdate) {
|
|
lane.lastPartialText = activeChunk;
|
|
lane.hasStreamedMessage = true;
|
|
lane.finalized = false;
|
|
stream.update(activeChunk);
|
|
}
|
|
if (finalizePreview) {
|
|
await params.stopDraftLane(lane);
|
|
} else {
|
|
await params.flushDraftLane(lane);
|
|
}
|
|
const activeChunkIndexAfterStop = useFinalTextRecovery
|
|
? clampActiveChunkIndex()
|
|
: activeChunkIndex;
|
|
const deliveredStreamTextAfterStop = stream.lastDeliveredText?.();
|
|
const retainedOriginalActiveChunkAfterStop =
|
|
activeChunkIndexAfterStop > activeChunkIndex &&
|
|
deliveredStreamTextAfterStop === activeChunk.trimEnd();
|
|
// `activeChunkIndex` is advanced by retained preview callbacks. If callbacks
|
|
// outrun the stream's delivered text, trust the delivered text and replay the gap.
|
|
const effectiveActiveChunkIndexAfterStop = retainedOriginalActiveChunkAfterStop
|
|
? activeChunkIndex
|
|
: activeChunkIndexAfterStop;
|
|
const activeChunkAfterStop = chunks[effectiveActiveChunkIndexAfterStop] ?? activeChunk;
|
|
const remainingChunksAfterStop = chunks.slice(effectiveActiveChunkIndexAfterStop + 1);
|
|
|
|
const messageId = stream.messageId();
|
|
if (typeof messageId !== "number") {
|
|
if (finalizePreview && stream.sendMayHaveLanded?.()) {
|
|
lane.finalized = true;
|
|
params.markDelivered();
|
|
return result("preview-retained");
|
|
}
|
|
if (!finalizePreview) {
|
|
await discardUnmaterializedStream(lane);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
const activeChunkTextAfterStop = activeChunkAfterStop.trimEnd();
|
|
if (
|
|
finalizePreview &&
|
|
deliveredStreamTextAfterStop !== undefined &&
|
|
deliveredStreamTextAfterStop !== activeChunkTextAfterStop &&
|
|
!retainedOriginalActiveChunkAfterStop
|
|
) {
|
|
if (
|
|
useFinalTextRecovery &&
|
|
isDeliveredPrefix({ deliveredText: deliveredStreamTextAfterStop, finalText }) &&
|
|
deliveredStreamTextAfterStop.length > activeChunkTextAfterStop.length
|
|
) {
|
|
return await finalizeDeliveredPrefix(deliveredStreamTextAfterStop, messageId);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
if (deliveredPrefixBeforeUpdate && deliveredStreamTextAfterStop === undefined) {
|
|
return await finalizeDeliveredPrefix(deliveredStreamTextBeforeUpdate, messageId);
|
|
}
|
|
|
|
params.markDelivered();
|
|
let buttonsAttached = false;
|
|
if (buttons) {
|
|
try {
|
|
await params.editStreamMessage({
|
|
laneName,
|
|
messageId,
|
|
text: activeChunkAfterStop,
|
|
buttons,
|
|
});
|
|
buttonsAttached = true;
|
|
} catch (err) {
|
|
params.log(`telegram: ${laneName} stream button edit failed: ${String(err)}`);
|
|
}
|
|
}
|
|
|
|
if (finalizePreview) {
|
|
lane.finalized = true;
|
|
for (const chunk of remainingChunksAfterStop) {
|
|
if (chunk.trim().length === 0) {
|
|
continue;
|
|
}
|
|
await params.sendPayload(followUpPayload(payload, chunk));
|
|
}
|
|
return result("preview-finalized", {
|
|
content: text,
|
|
promptContextContent: activeChunkAfterStop,
|
|
messageId,
|
|
buttonsAttached,
|
|
});
|
|
}
|
|
|
|
return result("preview-updated");
|
|
};
|
|
|
|
return async ({
|
|
laneName,
|
|
text,
|
|
payload,
|
|
infoKind,
|
|
buttons,
|
|
finalizePreview: requestedFinalizePreview,
|
|
durable: requestedDurable,
|
|
}: DeliverLaneTextParams): Promise<LaneDeliveryResult> => {
|
|
const lane = params.lanes[laneName];
|
|
const reply = resolveSendableOutboundReplyParts(payload, { text });
|
|
const isDurableFinal = infoKind === "final";
|
|
const finalizePreview = requestedFinalizePreview ?? isDurableFinal;
|
|
const durable = requestedDurable ?? isDurableFinal;
|
|
const streamed = !reply.hasMedia
|
|
? await streamText(laneName, lane, text, payload, isDurableFinal, finalizePreview, buttons)
|
|
: undefined;
|
|
if (streamed) {
|
|
return streamed;
|
|
}
|
|
|
|
if (
|
|
finalizePreview &&
|
|
reply.hasMedia &&
|
|
lane.stream &&
|
|
lane.hasStreamedMessage &&
|
|
!lane.finalized &&
|
|
text.trim().length > 0
|
|
) {
|
|
const finalizedPreview = await streamText(
|
|
laneName,
|
|
lane,
|
|
text,
|
|
textOnlyPayload(payload),
|
|
isDurableFinal,
|
|
true,
|
|
buttons,
|
|
);
|
|
if (finalizedPreview) {
|
|
const stripButtons =
|
|
finalizedPreview.kind === "preview-finalized" &&
|
|
finalizedPreview.delivery.buttonsAttached === true;
|
|
const mediaText =
|
|
finalizedPreview.kind === "preview-finalized" ? finalizedPreview.delivery.content : text;
|
|
await params.sendPayload(
|
|
mediaOnlyPayload(payload, mediaText, {
|
|
stripButtons,
|
|
fallbackButtons: stripButtons ? undefined : buttons,
|
|
}),
|
|
{
|
|
durable,
|
|
},
|
|
);
|
|
return finalizedPreview;
|
|
}
|
|
}
|
|
|
|
if (finalizePreview) {
|
|
await clearUnfinalizedStream(lane);
|
|
}
|
|
|
|
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text), {
|
|
durable,
|
|
});
|
|
if (delivered && finalizePreview) {
|
|
lane.finalized = true;
|
|
}
|
|
return delivered ? result("sent") : result("skipped");
|
|
};
|
|
}
|