fix(telegram): prevent duplicate messages in DM draft streaming mode (#32118)

* fix(telegram): prevent duplicate messages in DM draft streaming mode

When using sendMessageDraft for DM streaming (streaming: 'partial'),
the draft bubble auto-converts to the final message. The code was
incorrectly falling through to sendPayload() after the draft was
finalized, causing a duplicate message.

This fix checks if we're in draft preview mode with hasStreamedMessage
and skips the sendPayload call, returning "preview-finalized" directly.

Key changes:
- Use hasStreamedMessage flag instead of previewRevision comparison
- Avoids double stopDraftLane calls by returning early
- Prevents duplicate messages when final text equals last streamed text

Root cause: In lane-delivery.ts, the final message handling logic
did not properly handle the DM draft flow where sendMessageDraft
creates a transient bubble that doesn't need a separate final send.

* fix(telegram): harden DM draft finalization path

* fix(telegram): require emitted draft preview for unchanged finals

* fix(telegram): require final draft text emission before finalize

* fix: update changelog for telegram draft finalization (#32118) (thanks @OpenCils)

---------

Co-authored-by: Ayaan Zaidi <zaidi@uplause.io>
This commit is contained in:
OpenCils
2026-03-03 20:04:46 +08:00
committed by GitHub
parent 627813aba4
commit 3fe4c19305
6 changed files with 233 additions and 10 deletions

View File

@@ -8,6 +8,7 @@ export type DraftLaneState = {
stream: TelegramDraftStream | undefined;
lastPartialText: string;
hasStreamedMessage: boolean;
previewRevisionBaseline: number;
};
export type ArchivedPreview = {
@@ -328,6 +329,43 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
!hasMedia && text.length > 0 && text.length <= params.draftMaxChars && !payload.isError;
if (infoKind === "final") {
const hasPreviewButtons = Boolean(previewButtons?.some((row) => row.length > 0));
const canFinalizeDraftPreviewDirectly =
isDraftPreviewLane(lane) &&
lane.hasStreamedMessage &&
canEditViaPreview &&
!hasPreviewButtons;
let draftPreviewStopped = false;
if (canFinalizeDraftPreviewDirectly) {
const previewRevisionBeforeFlush = lane.stream?.previewRevision?.() ?? 0;
const finalTextSnapshot = text.trimEnd();
const hasEmittedPreviewInCurrentLane =
previewRevisionBeforeFlush > lane.previewRevisionBaseline;
const deliveredPreviewTextBeforeFinal = lane.stream?.lastDeliveredText?.() ?? "";
const finalTextAlreadyDelivered =
deliveredPreviewTextBeforeFinal === finalTextSnapshot && hasEmittedPreviewInCurrentLane;
const unchangedFinalText = text === lane.lastPartialText;
lane.stream?.update(text);
await params.flushDraftLane(lane);
await params.stopDraftLane(lane);
draftPreviewStopped = true;
const previewUpdated = (lane.stream?.previewRevision?.() ?? 0) > previewRevisionBeforeFlush;
const deliveredPreviewTextAfterFinal =
lane.stream?.lastDeliveredText?.() ?? deliveredPreviewTextBeforeFinal;
if (
(previewUpdated && deliveredPreviewTextAfterFinal === finalTextSnapshot) ||
(unchangedFinalText && finalTextAlreadyDelivered)
) {
lane.lastPartialText = text;
params.finalizedPreviewByLane[laneName] = true;
params.markDelivered();
return "preview-finalized";
}
params.log(
`telegram: ${laneName} draft final text not emitted; falling back to standard send`,
);
}
if (laneName === "answer") {
const archivedResult = await consumeArchivedAnswerPreviewForFinal({
lane,
@@ -340,7 +378,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
return archivedResult;
}
}
if (canEditViaPreview && !params.finalizedPreviewByLane[laneName]) {
if (canEditViaPreview && !params.finalizedPreviewByLane[laneName] && !draftPreviewStopped) {
await params.flushDraftLane(lane);
if (laneName === "answer") {
const archivedResultAfterFlush = await consumeArchivedAnswerPreviewForFinal({
@@ -372,7 +410,9 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
`telegram: preview final too long for edit (${text.length} > ${params.draftMaxChars}); falling back to standard send`,
);
}
await params.stopDraftLane(lane);
if (!draftPreviewStopped) {
await params.stopDraftLane(lane);
}
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
return delivered ? "sent" : "skipped";
}