Skip to content

Commit 3de57a6

Browse files
committed
fix(telegram): gate media dedup on visible delivery
1 parent 4769246 commit 3de57a6

3 files changed

Lines changed: 53 additions & 48 deletions

File tree

extensions/telegram/src/bot-message-dispatch.media-dedup.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,3 @@
1-
/**
2-
* Deduplicate media URLs in a final-reply payload against media already
3-
* delivered via block replies. Returns the deduplicated payload, or
4-
* undefined if the payload should be skipped entirely (all media already
5-
* sent and no text remains).
6-
*/
71
export function deduplicateBlockSentMedia<
82
T extends { mediaUrl?: string; mediaUrls?: string[]; text?: string },
93
>(payload: T, sentBlockMediaUrls: ReadonlySet<string>): T | undefined {

extensions/telegram/src/bot-message-dispatch.test.ts

Lines changed: 37 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1587,19 +1587,48 @@ describe("dispatchTelegramMessage draft streaming", () => {
15871587
});
15881588

15891589
describe("non-streaming media dedup", () => {
1590+
const finalDeliveryPayload = () => {
1591+
for (const [params] of deliverInboundReplyWithMessageSendContext.mock.calls) {
1592+
if (params.info.kind === "final") {
1593+
return params.payload;
1594+
}
1595+
}
1596+
throw new Error("missing final delivery");
1597+
};
1598+
15901599
it("deduplicates block-sent media from final reply", async () => {
1591-
sendMessageTelegram.mockResolvedValue({ message_id: 100 });
1600+
deliverReplies.mockResolvedValue({ delivered: true });
15921601
deliverInboundReplyWithMessageSendContext.mockResolvedValue({
15931602
status: "handled_visible",
15941603
delivery: { messageIds: ["101"], visibleReplySent: true },
15951604
});
15961605
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
1597-
// Block delivers media successfully
1606+
await dispatcherOptions.deliver({ mediaUrls: ["/tmp/cat.jpg"] }, { kind: "block" });
15981607
await dispatcherOptions.deliver(
15991608
{ text: "Here is the image", mediaUrls: ["/tmp/cat.jpg"] },
1600-
{ kind: "block" },
1609+
{ kind: "final" },
16011610
);
1602-
// Final reply has same media — should be deduped
1611+
return { queuedFinal: true };
1612+
});
1613+
1614+
await dispatchWithContext({
1615+
context: createContext(),
1616+
streamMode: "off",
1617+
telegramDeps: telegramDepsForTest,
1618+
});
1619+
1620+
expect(finalDeliveryPayload().mediaUrls).toEqual([]);
1621+
});
1622+
1623+
it("preserves final media when block delivery reports no visible send", async () => {
1624+
deliverReplies.mockResolvedValueOnce({ delivered: false });
1625+
deliverReplies.mockResolvedValue({ delivered: true });
1626+
deliverInboundReplyWithMessageSendContext.mockResolvedValue({
1627+
status: "handled_visible",
1628+
delivery: { messageIds: ["101"], visibleReplySent: true },
1629+
});
1630+
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
1631+
await dispatcherOptions.deliver({ mediaUrls: ["/tmp/cat.jpg"] }, { kind: "block" });
16031632
await dispatcherOptions.deliver(
16041633
{ text: "Here is the image", mediaUrls: ["/tmp/cat.jpg"] },
16051634
{ kind: "final" },
@@ -1613,12 +1642,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
16131642
telegramDeps: telegramDepsForTest,
16141643
});
16151644

1616-
// Final delivery should have empty mediaUrls (deduped)
1617-
const finalCall = deliverInboundReplyWithMessageSendContext.mock.calls.find(
1618-
(c: unknown[]) => (c[0] as { info: { kind: string } }).info.kind === "final",
1619-
);
1620-
expect(finalCall).toBeDefined();
1621-
expect((finalCall![0] as { payload: { mediaUrls: string[] } }).payload.mediaUrls).toEqual([]);
1645+
expect(finalDeliveryPayload().mediaUrls).toEqual(["/tmp/cat.jpg"]);
16221646
});
16231647

16241648
it("preserves final media when block delivery fails", async () => {
@@ -1629,16 +1653,9 @@ describe("dispatchTelegramMessage draft streaming", () => {
16291653
delivery: { messageIds: ["101"], visibleReplySent: true },
16301654
});
16311655
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
1632-
// Block delivery will throw (deliverReplies rejects)
16331656
try {
1634-
await dispatcherOptions.deliver(
1635-
{ text: "Here is the image", mediaUrls: ["/tmp/cat.jpg"] },
1636-
{ kind: "block" },
1637-
);
1638-
} catch {
1639-
// Block send failed — simulates dispatcher .catch() behavior
1640-
}
1641-
// Final reply has same media — should NOT be deduped since block failed
1657+
await dispatcherOptions.deliver({ mediaUrls: ["/tmp/cat.jpg"] }, { kind: "block" });
1658+
} catch {}
16421659
await dispatcherOptions.deliver(
16431660
{ text: "Here is the image", mediaUrls: ["/tmp/cat.jpg"] },
16441661
{ kind: "final" },
@@ -1652,14 +1669,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
16521669
telegramDeps: telegramDepsForTest,
16531670
});
16541671

1655-
// Final delivery should still have media since block failed
1656-
const finalCall = deliverInboundReplyWithMessageSendContext.mock.calls.find(
1657-
(c: unknown[]) => (c[0] as { info: { kind: string } }).info.kind === "final",
1658-
);
1659-
expect(finalCall).toBeDefined();
1660-
expect((finalCall![0] as { payload: { mediaUrls: string[] } }).payload.mediaUrls).toEqual([
1661-
"/tmp/cat.jpg",
1662-
]);
1672+
expect(finalDeliveryPayload().mediaUrls).toEqual(["/tmp/cat.jpg"]);
16631673
});
16641674
});
16651675
});

extensions/telegram/src/bot-message-dispatch.ts

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,10 +1013,6 @@ export const dispatchTelegramMessage = async ({
10131013
recordInboundSession: context.turn.recordInboundSession,
10141014
record: context.turn.record,
10151015
runDispatch: () => {
1016-
// Track media URLs delivered via block replies so final replies
1017-
// can skip duplicates. Without this, non-streaming Telegram
1018-
// delivers each MEDIA: attachment twice — once from the
1019-
// media-only block reply and once from the final reply.
10201016
const sentBlockMediaUrls = new Set<string>();
10211017

10221018
return telegramDeps.dispatchReplyWithBufferedBlockDispatcher({
@@ -1033,7 +1029,6 @@ export const dispatchTelegramMessage = async ({
10331029
hadErrorReplyFailureOrSkip = true;
10341030
}
10351031

1036-
// Filter out media already sent via block reply.
10371032
const deduped =
10381033
info.kind === "final"
10391034
? deduplicateBlockSentMedia(payload, sentBlockMediaUrls)
@@ -1061,7 +1056,10 @@ export const dispatchTelegramMessage = async ({
10611056
| { buttons?: TelegramInlineButtons }
10621057
| undefined
10631058
)?.buttons;
1064-
const split = splitTextIntoLaneSegments(effectivePayload.text, payload.isReasoning);
1059+
const split = splitTextIntoLaneSegments(
1060+
effectivePayload.text,
1061+
payload.isReasoning,
1062+
);
10651063
const segments = split.segments;
10661064
const reply = resolveSendableOutboundReplyParts(effectivePayload);
10671065
const _hasMedia = reply.hasMedia;
@@ -1087,6 +1085,7 @@ export const dispatchTelegramMessage = async ({
10871085
reasoningStepState.resetForNextStep();
10881086
};
10891087

1088+
let blockDelivered = false;
10901089
for (const segment of segments) {
10911090
if (
10921091
segment.lane === "answer" &&
@@ -1118,6 +1117,7 @@ export const dispatchTelegramMessage = async ({
11181117
if (info.kind === "final") {
11191118
emitPreviewFinalizedHook(result);
11201119
}
1120+
blockDelivered = blockDelivered || result.kind !== "skipped";
11211121
if (segment.lane === "reasoning") {
11221122
if (result.kind !== "skipped") {
11231123
reasoningStepState.noteReasoningDelivered();
@@ -1129,34 +1129,33 @@ export const dispatchTelegramMessage = async ({
11291129
reasoningStepState.resetForNextStep();
11301130
}
11311131
}
1132-
// Track block media only after successful delivery so
1133-
// failed block sends do not prevent final fallback media.
1134-
const trackBlockMedia = () => {
1135-
if (info.kind === "block" && payload.mediaUrls?.length) {
1132+
const trackBlockMedia = (delivered: boolean) => {
1133+
if (delivered && info.kind === "block" && payload.mediaUrls?.length) {
11361134
for (const url of payload.mediaUrls) {
11371135
sentBlockMediaUrls.add(url);
11381136
}
11391137
}
11401138
};
11411139

11421140
if (segments.length > 0) {
1143-
trackBlockMedia();
1141+
trackBlockMedia(blockDelivered);
11441142
return;
11451143
}
11461144
if (split.suppressedReasoningOnly) {
1145+
let delivered = false;
11471146
if (reply.hasMedia) {
11481147
const payloadWithoutSuppressedReasoning =
11491148
typeof effectivePayload.text === "string"
11501149
? { ...effectivePayload, text: "" }
11511150
: effectivePayload;
1152-
await sendPayload(payloadWithoutSuppressedReasoning, {
1151+
delivered = await sendPayload(payloadWithoutSuppressedReasoning, {
11531152
durable: info.kind === "final",
11541153
});
11551154
}
11561155
if (info.kind === "final") {
11571156
await flushBufferedFinalAnswer();
11581157
}
1159-
trackBlockMedia();
1158+
trackBlockMedia(delivered);
11601159
return;
11611160
}
11621161

@@ -1172,11 +1171,13 @@ export const dispatchTelegramMessage = async ({
11721171
}
11731172
return;
11741173
}
1175-
await sendPayload(effectivePayload, { durable: info.kind === "final" });
1174+
const delivered = await sendPayload(effectivePayload, {
1175+
durable: info.kind === "final",
1176+
});
11761177
if (info.kind === "final") {
11771178
await flushBufferedFinalAnswer();
11781179
}
1179-
trackBlockMedia();
1180+
trackBlockMedia(delivered);
11801181
},
11811182
onSkip: (payload, info) => {
11821183
if (payload.isError === true) {

0 commit comments

Comments
 (0)