fix: prevent Telegram preview stream cross-edit race (#23202)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 529abf209d
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
This commit is contained in:
Ayaan Zaidi
2026-02-22 10:04:33 +05:30
committed by GitHub
parent 413f81b856
commit 63b4c500d9
5 changed files with 346 additions and 60 deletions

View File

@@ -1,3 +1,4 @@
import type { Bot } from "grammy";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTelegramDraftStream } from "./draft-stream.js";
@@ -18,8 +19,7 @@ function createThreadedDraftStream(
thread: { id: number; scope: "forum" | "dm" },
) {
return createTelegramDraftStream({
// oxlint-disable-next-line typescript/no-explicit-any
api: api as any,
api: api as unknown as Bot["api"],
chatId: 123,
thread,
});
@@ -109,8 +109,7 @@ describe("createTelegramDraftStream", () => {
deleteMessage: vi.fn().mockResolvedValue(true),
};
const stream = createTelegramDraftStream({
// oxlint-disable-next-line typescript/no-explicit-any
api: api as any,
api: api as unknown as Bot["api"],
chatId: 123,
});
@@ -146,8 +145,7 @@ describe("createTelegramDraftStream", () => {
deleteMessage: vi.fn().mockResolvedValue(true),
};
const stream = createTelegramDraftStream({
// oxlint-disable-next-line typescript/no-explicit-any
api: api as any,
api: api as unknown as Bot["api"],
chatId: 123,
throttleMs: 1000,
});
@@ -167,11 +165,47 @@ describe("createTelegramDraftStream", () => {
}
});
it("does not rebind to an old message when forceNewMessage races an in-flight send", async () => {
let resolveFirstSend: ((value: { message_id: number }) => void) | undefined;
const firstSend = new Promise<{ message_id: number }>((resolve) => {
resolveFirstSend = resolve;
});
const api = {
sendMessage: vi.fn().mockReturnValueOnce(firstSend).mockResolvedValueOnce({ message_id: 42 }),
editMessageText: vi.fn().mockResolvedValue(true),
deleteMessage: vi.fn().mockResolvedValue(true),
};
const onSupersededPreview = vi.fn();
const stream = createTelegramDraftStream({
api: api as unknown as Bot["api"],
chatId: 123,
onSupersededPreview,
});
stream.update("Message A partial");
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(1));
// Rotate to message B before message A send resolves.
stream.forceNewMessage();
stream.update("Message B partial");
resolveFirstSend?.({ message_id: 17 });
await stream.flush();
expect(onSupersededPreview).toHaveBeenCalledWith({
messageId: 17,
textSnapshot: "Message A partial",
parseMode: undefined,
});
expect(api.sendMessage).toHaveBeenCalledTimes(2);
expect(api.sendMessage).toHaveBeenNthCalledWith(2, 123, "Message B partial", undefined);
expect(api.editMessageText).not.toHaveBeenCalledWith(123, 17, "Message B partial");
});
it("supports rendered previews with parse_mode", async () => {
const api = createMockDraftApi();
const stream = createTelegramDraftStream({
// oxlint-disable-next-line typescript/no-explicit-any
api: api as any,
api: api as unknown as Bot["api"],
chatId: 123,
renderText: (text) => ({ text: `<i>${text}</i>`, parseMode: "HTML" }),
});
@@ -191,8 +225,7 @@ describe("createTelegramDraftStream", () => {
const api = createMockDraftApi();
const warn = vi.fn();
const stream = createTelegramDraftStream({
// oxlint-disable-next-line typescript/no-explicit-any
api: api as any,
api: api as unknown as Bot["api"],
chatId: 123,
maxChars: 100,
renderText: () => ({ text: `<b>${"<".repeat(120)}</b>`, parseMode: "HTML" }),
@@ -229,8 +262,7 @@ describe("draft stream initial message debounce", () => {
it("sends immediately on stop() even with 1 character", async () => {
const api = createMockApi();
const stream = createTelegramDraftStream({
// oxlint-disable-next-line typescript/no-explicit-any
api: api as any,
api: api as unknown as Bot["api"],
chatId: 123,
minInitialChars: 30,
});
@@ -245,8 +277,7 @@ describe("draft stream initial message debounce", () => {
it("sends immediately on stop() with short sentence", async () => {
const api = createMockApi();
const stream = createTelegramDraftStream({
// oxlint-disable-next-line typescript/no-explicit-any
api: api as any,
api: api as unknown as Bot["api"],
chatId: 123,
minInitialChars: 30,
});
@@ -263,8 +294,7 @@ describe("draft stream initial message debounce", () => {
it("does not send first message below threshold", async () => {
const api = createMockApi();
const stream = createTelegramDraftStream({
// oxlint-disable-next-line typescript/no-explicit-any
api: api as any,
api: api as unknown as Bot["api"],
chatId: 123,
minInitialChars: 30,
});
@@ -278,8 +308,7 @@ describe("draft stream initial message debounce", () => {
it("sends first message when reaching threshold", async () => {
const api = createMockApi();
const stream = createTelegramDraftStream({
// oxlint-disable-next-line typescript/no-explicit-any
api: api as any,
api: api as unknown as Bot["api"],
chatId: 123,
minInitialChars: 30,
});
@@ -294,8 +323,7 @@ describe("draft stream initial message debounce", () => {
it("works with longer text above threshold", async () => {
const api = createMockApi();
const stream = createTelegramDraftStream({
// oxlint-disable-next-line typescript/no-explicit-any
api: api as any,
api: api as unknown as Bot["api"],
chatId: 123,
minInitialChars: 30,
});
@@ -311,8 +339,7 @@ describe("draft stream initial message debounce", () => {
it("edits normally after first message is sent", async () => {
const api = createMockApi();
const stream = createTelegramDraftStream({
// oxlint-disable-next-line typescript/no-explicit-any
api: api as any,
api: api as unknown as Bot["api"],
chatId: 123,
minInitialChars: 30,
});
@@ -335,8 +362,7 @@ describe("draft stream initial message debounce", () => {
it("sends immediately without minInitialChars set (backward compatible)", async () => {
const api = createMockApi();
const stream = createTelegramDraftStream({
// oxlint-disable-next-line typescript/no-explicit-any
api: api as any,
api: api as unknown as Bot["api"],
chatId: 123,
// no minInitialChars (backward-compatible behavior)
});