From 375494ea7b52689c76934623b93bc6276f94afb3 Mon Sep 17 00:00:00 2001 From: Ali Zahid Date: Thu, 19 Mar 2026 12:44:57 +0500 Subject: [PATCH] Support for Telegram attachments --- .../adapter-shared/src/adapter-utils.test.ts | 123 +++++++++++++++++- packages/adapter-shared/src/adapter-utils.ts | 43 +++++- packages/adapter-shared/src/index.ts | 2 +- packages/adapter-telegram/src/index.ts | 101 ++++++++++++++ 4 files changed, 265 insertions(+), 4 deletions(-) diff --git a/packages/adapter-shared/src/adapter-utils.test.ts b/packages/adapter-shared/src/adapter-utils.test.ts index 7a21616c..4fba5c39 100644 --- a/packages/adapter-shared/src/adapter-utils.test.ts +++ b/packages/adapter-shared/src/adapter-utils.test.ts @@ -2,10 +2,10 @@ * Tests for shared adapter utility functions. */ -import type { AdapterPostableMessage, FileUpload } from "chat"; +import type { AdapterPostableMessage, Attachment, FileUpload } from "chat"; import { Card, CardText } from "chat"; import { describe, expect, it } from "vitest"; -import { extractCard, extractFiles } from "./adapter-utils"; +import { extractAttachments, extractCard, extractFiles } from "./adapter-utils"; describe("extractCard", () => { describe("with CardElement", () => { @@ -228,3 +228,122 @@ describe("extractFiles", () => { }); }); }); + +describe("extractAttachments", () => { + describe("with attachments present", () => { + it("extracts attachments array from PostableRaw", () => { + const attachments: Attachment[] = [ + { data: Buffer.from("content1"), name: "file1.txt", type: "file" }, + { data: Buffer.from("content2"), name: "file2.txt", type: "file" }, + ]; + const message: AdapterPostableMessage = { raw: "Text", attachments }; + const result = extractAttachments(message); + expect(result).toBe(attachments); + expect(result).toHaveLength(2); + }); + + it("extracts attachments array from PostableMarkdown", () => { + const attachments: Attachment[] = [ + { + data: Buffer.from("image"), + name: "image.png", + mimeType: "image/png", + type: "image", + }, + ]; + const message: AdapterPostableMessage = { + markdown: "**Text**", + attachments, + }; + const result = extractAttachments(message); + expect(result).toEqual(attachments); + expect(result[0].mimeType).toBe("image/png"); + }); + + it("extracts attachments array from PostableCard", () => { + const card = Card({ title: "Test" }); + const attachments: Attachment[] = [ + { data: Buffer.from("doc"), name: "doc.pdf", type: "file" }, + ]; + const message: AdapterPostableMessage = { card, attachments }; + const result = extractAttachments(message); + expect(result).toBe(attachments); + }); + + it("handles Blob data in attachments", () => { + const blob = new Blob(["content"], { type: "text/plain" }); + const attachments: Attachment[] = [ + { data: blob, name: "blob.txt", type: "file" }, + ]; + const message: AdapterPostableMessage = { raw: "Text", attachments }; + const result = extractAttachments(message); + expect(result).toHaveLength(1); + expect(result[0].data).toBe(blob); + }); + + it("handles ArrayBuffer data in attachments", () => { + const buffer = new ArrayBuffer(8); + const attachments: Attachment[] = [ + { data: buffer, name: "binary.bin", type: "file" }, + ]; + const message: AdapterPostableMessage = { raw: "Text", attachments }; + const result = extractAttachments(message); + expect(result).toHaveLength(1); + expect(result[0].data).toBe(buffer); + }); + }); + + describe("with empty or missing attachments", () => { + it("returns empty array when attachments property is empty array", () => { + const message: AdapterPostableMessage = { raw: "Text", attachments: [] }; + const result = extractAttachments(message); + expect(result).toEqual([]); + }); + + it("returns empty array when attachments property is undefined", () => { + const message = { + raw: "Text", + attachments: undefined, + } as AdapterPostableMessage; + const result = extractAttachments(message); + expect(result).toEqual([]); + }); + + it("returns empty array for PostableRaw without attachments", () => { + const message: AdapterPostableMessage = { raw: "Just text" }; + const result = extractAttachments(message); + expect(result).toEqual([]); + }); + + it("returns empty array for PostableMarkdown without attachments", () => { + const message: AdapterPostableMessage = { markdown: "**Bold**" }; + const result = extractAttachments(message); + expect(result).toEqual([]); + }); + }); + + describe("with non-object messages", () => { + it("returns empty array for plain string", () => { + const result = extractAttachments("Hello world"); + expect(result).toEqual([]); + }); + + it("returns empty array for CardElement (no attachments property)", () => { + const card = Card({ title: "Test" }); + const result = extractAttachments(card); + expect(result).toEqual([]); + }); + + it("returns empty array for null input", () => { + // @ts-expect-error testing invalid input + const result = extractAttachments(null); + expect(result).toEqual([]); + }); + + it("returns empty array for undefined input", () => { + // @ts-expect-error testing invalid input + const result = extractAttachments(undefined); + expect(result).toEqual([]); + }); + }); +}); diff --git a/packages/adapter-shared/src/adapter-utils.ts b/packages/adapter-shared/src/adapter-utils.ts index 3f2f3351..fc376dc0 100644 --- a/packages/adapter-shared/src/adapter-utils.ts +++ b/packages/adapter-shared/src/adapter-utils.ts @@ -5,7 +5,12 @@ * to reduce code duplication and ensure consistent behavior. */ -import type { AdapterPostableMessage, CardElement, FileUpload } from "chat"; +import type { + AdapterPostableMessage, + Attachment, + CardElement, + FileUpload, +} from "chat"; import { isCardElement } from "chat"; /** @@ -74,3 +79,39 @@ export function extractFiles(message: AdapterPostableMessage): FileUpload[] { } return []; } + +/** + * Extract Attachment array from an AdapterPostableMessage if present. + * + * Attachments can be attached to PostableRaw, PostableMarkdown, PostableAst, + * or PostableCard messages via the `attachments` property. + * + * @param message - The message to extract attachments from + * @returns Array of Attachment objects, or empty array if none + * + * @example + * ```typescript + * // With attachments + * const message = { + * markdown: "**Text**", + * attachments: [{ data: Buffer.from("..."), name: "doc.pdf" }] + * }; + * extractAttachments(message); // returns the attachments array + * + * // Without attachments + * extractAttachments("Hello"); // returns [] + * extractAttachments({ raw: "text" }); // returns [] + * ``` + */ +export function extractAttachments( + message: AdapterPostableMessage +): Attachment[] { + if ( + typeof message === "object" && + message !== null && + "attachments" in message + ) { + return (message as { attachments?: Attachment[] }).attachments ?? []; + } + return []; +} diff --git a/packages/adapter-shared/src/index.ts b/packages/adapter-shared/src/index.ts index 53b9310a..b0ae5354 100644 --- a/packages/adapter-shared/src/index.ts +++ b/packages/adapter-shared/src/index.ts @@ -6,7 +6,7 @@ */ // Adapter utilities -export { extractCard, extractFiles } from "./adapter-utils"; +export { extractAttachments, extractCard, extractFiles } from "./adapter-utils"; // Buffer conversion utilities export { diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index a4028457..b4fad42e 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -2,6 +2,7 @@ import { AdapterRateLimitError, AuthenticationError, cardToFallbackText, + extractAttachments, extractCard, extractFiles, NetworkError, @@ -185,6 +186,25 @@ export function applyTelegramEntities( return result; } +const attachmentsMap = { + audio: { + method: "sendAudio", + field: "audio", + }, + file: { + method: "sendDocument", + field: "document", + }, + image: { + method: "sendPhoto", + field: "photo", + }, + video: { + method: "sendVideo", + field: "video", + }, +} as const; + export class TelegramAdapter implements Adapter { @@ -658,6 +678,21 @@ export class TelegramAdapter ); } + const attachments = extractAttachments(message); + if (attachments.length > 1) { + throw new ValidationError( + "telegram", + "Telegram adapter supports a single attachment upload per message" + ); + } + + if (files.length > 0 && attachments.length > 0) { + throw new ValidationError( + "telegram", + "Telegram adapter does not support mixing file uploads and file attachments in one message" + ); + } + let rawMessage: TelegramMessage; if (files.length === 1) { @@ -672,6 +707,21 @@ export class TelegramAdapter replyMarkup, parseMode ); + } else if (attachments.length === 1) { + const [attachment] = attachments; + if (!attachment) { + throw new ValidationError( + "telegram", + "Attachment upload payload is empty" + ); + } + rawMessage = await this.sendAttachment( + parsedThread, + attachment, + text, + replyMarkup, + parseMode + ); } else { if (!text.trim()) { throw new ValidationError("telegram", "Message text cannot be empty"); @@ -1212,6 +1262,57 @@ export class TelegramAdapter return this.telegramFetch("sendDocument", formData); } + private async sendAttachment( + thread: TelegramThreadId, + attachment: Attachment, + text: string, + replyMarkup?: TelegramInlineKeyboardMarkup, + parseMode?: string + ) { + const data = + attachment.data ?? + (attachment.fetchData ? await attachment.fetchData() : null); + + if (!data) { + throw new ValidationError( + "telegram", + `Attachment data required for ${attachment.type}` + ); + } + + const buffer = await this.toTelegramBuffer(data); + + const formData = new FormData(); + + formData.append("chat_id", thread.chatId); + + if (typeof thread.messageThreadId === "number") { + formData.append("message_thread_id", String(thread.messageThreadId)); + } + + if (text.trim()) { + formData.append("caption", this.truncateCaption(text)); + + if (parseMode) { + formData.append("parse_mode", parseMode); + } + } + + const blob = new Blob([new Uint8Array(buffer)], { + type: attachment.mimeType, + }); + + const { method, field } = attachmentsMap[attachment.type]; + + formData.append(field, blob, attachment.name ?? "attachment"); + + if (replyMarkup) { + formData.append("reply_markup", JSON.stringify(replyMarkup)); + } + + return this.telegramFetch(method, formData); + } + private async toTelegramBuffer( data: Buffer | Blob | ArrayBuffer ): Promise {