Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 121 additions & 2 deletions packages/adapter-shared/src/adapter-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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([]);
});
});
});
43 changes: 42 additions & 1 deletion packages/adapter-shared/src/adapter-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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 [];
}
2 changes: 1 addition & 1 deletion packages/adapter-shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

// Adapter utilities
export { extractCard, extractFiles } from "./adapter-utils";
export { extractAttachments, extractCard, extractFiles } from "./adapter-utils";

// Buffer conversion utilities
export {
Expand Down
101 changes: 101 additions & 0 deletions packages/adapter-telegram/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
AdapterRateLimitError,
AuthenticationError,
cardToFallbackText,
extractAttachments,
extractCard,
extractFiles,
NetworkError,
Expand Down Expand Up @@ -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<TelegramThreadId, TelegramRawMessage>
{
Expand Down Expand Up @@ -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) {
Expand All @@ -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");
Expand Down Expand Up @@ -1212,6 +1262,57 @@ export class TelegramAdapter
return this.telegramFetch<TelegramMessage>("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<TelegramMessage>(method, formData);
}

private async toTelegramBuffer(
data: Buffer | Blob | ArrayBuffer
): Promise<Buffer> {
Expand Down