diff --git a/worker/src/upload/forward.test.ts b/worker/src/upload/forward.test.ts new file mode 100644 index 0000000..a46fee1 --- /dev/null +++ b/worker/src/upload/forward.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from "vitest"; +import { forwardArchiveToChannel } from "./forward.js"; + +function fakeClient(response: unknown) { + return { invoke: vi.fn(async () => response) } as never; +} + +describe("forwardArchiveToChannel", () => { + it("sorts message ids ascending and sends them via forwardMessages", async () => { + const invoke = vi.fn(async (req: { message_ids: number[] }) => ({ + messages: req.message_ids.map((id) => ({ id: id + 1000 })), + })); + const client = { invoke } as never; + + const result = await forwardArchiveToChannel(client, 111n, 222n, [30n, 10n, 20n]); + + expect(invoke).toHaveBeenCalledWith( + expect.objectContaining({ + _: "forwardMessages", + chat_id: 222, + from_chat_id: 111, + message_ids: [10, 20, 30], + send_copy: false, + }), + ); + expect(result.messageId).toBe(1010n); + expect(result.messageIds).toEqual([1010n, 1020n, 1030n]); + }); + + it("throws when Telegram returns null for a message (can't be forwarded)", async () => { + const client = fakeClient({ messages: [{ id: 1001 }, null] }); + await expect(forwardArchiveToChannel(client, 111n, 222n, [10n, 20n])).rejects.toThrow(/could not forward/); + }); + + it("throws when the response has the wrong number of messages", async () => { + const client = fakeClient({ messages: [{ id: 1001 }] }); + await expect(forwardArchiveToChannel(client, 111n, 222n, [10n, 20n])).rejects.toThrow(/expected 2/); + }); +}); diff --git a/worker/src/upload/forward.ts b/worker/src/upload/forward.ts new file mode 100644 index 0000000..16c5025 --- /dev/null +++ b/worker/src/upload/forward.ts @@ -0,0 +1,73 @@ +import type { Client } from "tdl"; +import { childLogger } from "../util/logger.js"; +import { withFloodWait } from "../util/retry.js"; + +const log = childLogger("forward"); + +export interface ForwardResult { + messageId: bigint; + messageIds: bigint[]; +} + +/** + * Forward all parts of an archive set from the source chat directly to the + * destination chat via TDLib's forwardMessages — no download, no re-upload. + * Only usable when the source channel allows forwarding + * (TelegramChannel.allowsForwarding); the caller is responsible for that + * check. message_ids must be in strictly increasing order per the TDLib API, + * so this always sorts them regardless of the order they're passed in. + */ +export async function forwardArchiveToChannel( + client: Client, + fromChatId: bigint, + toChatId: bigint, + sourceMessageIds: bigint[], +): Promise { + const sortedIds = [...sourceMessageIds].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + const numericIds = sortedIds.map((id) => Number(id)); + + log.info( + { fromChatId: Number(fromChatId), toChatId: Number(toChatId), count: numericIds.length }, + "Forwarding archive to destination channel" + ); + + const result = (await withFloodWait( + () => + client.invoke({ + _: "forwardMessages", + chat_id: Number(toChatId), + topic_id: null, + from_chat_id: Number(fromChatId), + message_ids: numericIds, + options: null, + send_copy: false, + remove_caption: false, + } as never), + "forwardMessages" + )) as { messages: ({ id: number } | null)[] }; + + const forwarded = result.messages; + if (!forwarded || forwarded.length !== numericIds.length) { + throw new Error( + `forwardMessages returned ${forwarded?.length ?? 0} messages, expected ${numericIds.length}` + ); + } + + const messageIds: bigint[] = []; + for (let i = 0; i < forwarded.length; i++) { + const msg = forwarded[i]; + if (!msg) { + throw new Error( + `forwardMessages could not forward source message ${sortedIds[i]} (Telegram returned null — message may not be forwardable)` + ); + } + messageIds.push(BigInt(msg.id)); + } + + log.info( + { fromChatId: Number(fromChatId), toChatId: Number(toChatId), messageIds: messageIds.map(Number) }, + "Forward confirmed by Telegram" + ); + + return { messageId: messageIds[0], messageIds }; +}