diff --git a/.changeset/better-eagles-serve.md b/.changeset/better-eagles-serve.md new file mode 100644 index 00000000..a3d67175 --- /dev/null +++ b/.changeset/better-eagles-serve.md @@ -0,0 +1,10 @@ +--- +"@chat-adapter/telegram": patch +"@chat-adapter/whatsapp": patch +"@chat-adapter/gchat": patch +"@chat-adapter/slack": patch +"@chat-adapter/teams": patch +"chat": minor +--- + +restore attachment fetchData after queue/debounce serialization diff --git a/apps/docs/content/docs/api/message.mdx b/apps/docs/content/docs/api/message.mdx index 54676a6c..e63738cc 100644 --- a/apps/docs/content/docs/api/message.mdx +++ b/apps/docs/content/docs/api/message.mdx @@ -149,6 +149,10 @@ All adapters return `false` if the bot ID isn't known yet. This is a safe defaul description: 'Fetch the attachment data. Handles platform auth automatically.', type: '() => Promise | undefined', }, + fetchMetadata: { + description: 'Platform-specific IDs for reconstructing fetchData after serialization (e.g. WhatsApp mediaId, Telegram fileId).', + type: 'Record | undefined', + }, }} /> @@ -208,4 +212,4 @@ const json = message.toJSON(); const restored = Message.fromJSON(json); ``` -The serialized format converts `Date` fields to ISO strings and omits non-serializable fields like `data` buffers and `fetchData` functions. +The serialized format converts `Date` fields to ISO strings and omits non-serializable fields like `data` buffers and `fetchData` functions. The `fetchMetadata` field is preserved so that adapters can reconstruct `fetchData` when the message is rehydrated from a queue. diff --git a/apps/docs/content/docs/files.mdx b/apps/docs/content/docs/files.mdx index a5329947..5ef29554 100644 --- a/apps/docs/content/docs/files.mdx +++ b/apps/docs/content/docs/files.mdx @@ -75,3 +75,4 @@ bot.onSubscribedMessage(async (thread, message) => { | `width` | `number` (optional) | Image width | | `height` | `number` (optional) | Image height | | `fetchData` | `() => Promise` (optional) | Download the file data | +| `fetchMetadata` | `Record` (optional) | Platform-specific IDs for reconstructing `fetchData` after serialization | diff --git a/packages/adapter-gchat/src/index.ts b/packages/adapter-gchat/src/index.ts index 743b2802..8721b89e 100644 --- a/packages/adapter-gchat/src/index.ts +++ b/packages/adapter-gchat/src/index.ts @@ -1449,9 +1449,7 @@ export class GoogleChatAdapter implements Adapter { }): Attachment { const url = att.downloadUri || undefined; const resourceName = att.attachmentDataRef?.resourceName || undefined; - const chatApi = this.chatApi; - // Determine type based on contentType let type: Attachment["type"] = "file"; if (att.contentType?.startsWith("image/")) { type = "image"; @@ -1461,62 +1459,85 @@ export class GoogleChatAdapter implements Adapter { type = "audio"; } - // Capture auth client for use in fetchData closure (used for URL fallback) - const auth = this.authClient; + const fetchMeta: Record = {}; + if (resourceName) { + fetchMeta.resourceName = resourceName; + } + if (url) { + fetchMeta.url = url; + } return { type, url, name: att.contentName || undefined, mimeType: att.contentType || undefined, + fetchMetadata: Object.keys(fetchMeta).length > 0 ? fetchMeta : undefined, fetchData: resourceName || url - ? async () => { - // Prefer media.download API (correct method for chat apps) - if (resourceName) { - const res = await chatApi.media.download( - { resourceName }, - { responseType: "arraybuffer" } - ); - return Buffer.from(res.data as ArrayBuffer); - } - - // Fallback to direct URL fetch (downloadUri) - if (typeof auth === "string" || !auth) { - throw new AuthenticationError( - "gchat", - "Cannot fetch file: no auth client configured" - ); - } - const tokenResult = await auth.getAccessToken(); - const token = - typeof tokenResult === "string" - ? tokenResult - : tokenResult?.token; - if (!token) { - throw new AuthenticationError( - "gchat", - "Failed to get access token" - ); - } - const response = await fetch(url as string, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - if (!response.ok) { - throw new NetworkError( - "gchat", - `Failed to fetch file: ${response.status} ${response.statusText}` - ); - } - const arrayBuffer = await response.arrayBuffer(); - return Buffer.from(arrayBuffer); - } + ? () => this.fetchAttachmentData(resourceName, url) : undefined, }; } + private async fetchAttachmentData( + resourceName?: string, + url?: string + ): Promise { + // Prefer media.download API (correct method for chat apps) + if (resourceName) { + const res = await this.chatApi.media.download( + { resourceName }, + { responseType: "arraybuffer" } + ); + return Buffer.from(res.data as ArrayBuffer); + } + + // Fallback to direct URL fetch (downloadUri) + if (!url) { + throw new NetworkError("gchat", "No URL or resourceName available"); + } + + const auth = this.authClient; + if (typeof auth === "string" || !auth) { + throw new AuthenticationError( + "gchat", + "Cannot fetch file: no auth client configured" + ); + } + const tokenResult = await auth.getAccessToken(); + const token = + typeof tokenResult === "string" ? tokenResult : tokenResult?.token; + if (!token) { + throw new AuthenticationError("gchat", "Failed to get access token"); + } + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (!response.ok) { + throw new NetworkError( + "gchat", + `Failed to fetch file: ${response.status} ${response.statusText}` + ); + } + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); + } + + rehydrateAttachment(attachment: Attachment): Attachment { + const resourceName = attachment.fetchMetadata?.resourceName; + const url = attachment.fetchMetadata?.url ?? attachment.url; + if (!(resourceName || url)) { + return attachment; + } + return { + ...attachment, + fetchData: () => this.fetchAttachmentData(resourceName, url), + }; + } + async editMessage( threadId: string, messageId: string, diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index d25aab5a..41e593f8 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -5130,4 +5130,63 @@ describe("reverse user lookup", () => { expect(cached).toBeNull(); }); }); + + describe("rehydrateAttachment", () => { + it("should resolve token from installation when teamId is present", async () => { + const state = createMockState(); + const adapter = createSlackAdapter({ + signingSecret: secret, + clientId: "client-id", + clientSecret: "client-secret", + logger: mockLogger, + }); + await adapter.initialize(createMockChatInstance(state)); + + await adapter.setInstallation("T_MULTI_1", { + botToken: "xoxb-multi-workspace-token", + botUserId: "U_BOT_MULTI", + }); + + const rehydrated = adapter.rehydrateAttachment({ + type: "image", + url: "https://files.slack.com/img.png", + fetchMetadata: { + url: "https://files.slack.com/img.png", + teamId: "T_MULTI_1", + }, + }); + + expect(rehydrated.fetchData).toBeDefined(); + }); + + it("should fall back to getToken when no teamId in fetchMetadata", () => { + const adapter = createSlackAdapter({ + signingSecret: secret, + botToken: "xoxb-single", + logger: mockLogger, + }); + + const rehydrated = adapter.rehydrateAttachment({ + type: "image", + url: "https://files.slack.com/img.png", + fetchMetadata: { url: "https://files.slack.com/img.png" }, + }); + + expect(rehydrated.fetchData).toBeDefined(); + }); + + it("should return attachment unchanged when no url", () => { + const adapter = createSlackAdapter({ + signingSecret: secret, + botToken: "xoxb-test", + logger: mockLogger, + }); + + const attachment = { type: "file" as const, name: "test.bin" }; + const rehydrated = adapter.rehydrateAttachment(attachment); + + expect(rehydrated.fetchData).toBeUndefined(); + expect(rehydrated).toBe(attachment); + }); + }); }); diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index d523d695..784a242a 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -1963,7 +1963,7 @@ export class SlackAdapter implements Adapter { : undefined, }, attachments: (event.files || []).map((file) => - this.createAttachment(file) + this.createAttachment(file, event.team_id ?? event.team) ), links: this.extractLinks(event), }); @@ -1973,15 +1973,18 @@ export class SlackAdapter implements Adapter { * Create an Attachment object from a Slack file. * Includes a fetchData method that uses the bot token for auth. */ - private createAttachment(file: { - id?: string; - mimetype?: string; - url_private?: string; - name?: string; - size?: number; - original_w?: number; - original_h?: number; - }): Attachment { + private createAttachment( + file: { + id?: string; + mimetype?: string; + url_private?: string; + name?: string; + size?: number; + original_w?: number; + original_h?: number; + }, + teamId?: string + ): Attachment { const url = file.url_private; // Capture token at attachment creation time (during webhook processing context) const botToken = this.getToken(); @@ -1996,6 +1999,14 @@ export class SlackAdapter implements Adapter { type = "audio"; } + const fetchMeta: Record = {}; + if (url) { + fetchMeta.url = url; + } + if (teamId) { + fetchMeta.teamId = teamId; + } + return { type, url, @@ -2004,32 +2015,58 @@ export class SlackAdapter implements Adapter { size: file.size, width: file.original_w, height: file.original_h, - fetchData: url - ? async () => { - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${botToken}`, - }, - }); - if (!response.ok) { - throw new NetworkError( - "slack", - `Failed to fetch file: ${response.status} ${response.statusText}` - ); - } - const contentType = response.headers.get("content-type") ?? ""; - if (contentType.includes("text/html")) { - throw new NetworkError( - "slack", - "Failed to download file from Slack: received HTML login page instead of file data. " + - `Ensure your Slack app has the "files:read" OAuth scope. ` + - `URL: ${url}` - ); - } - const arrayBuffer = await response.arrayBuffer(); - return Buffer.from(arrayBuffer); + fetchMetadata: Object.keys(fetchMeta).length > 0 ? fetchMeta : undefined, + fetchData: url ? () => this.fetchSlackFile(url, botToken) : undefined, + }; + } + + private async fetchSlackFile(url: string, token: string): Promise { + const response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) { + throw new NetworkError( + "slack", + `Failed to fetch file: ${response.status} ${response.statusText}` + ); + } + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("text/html")) { + throw new NetworkError( + "slack", + "Failed to download file from Slack: received HTML login page instead of file data. " + + `Ensure your Slack app has the "files:read" OAuth scope. ` + + `URL: ${url}` + ); + } + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); + } + + rehydrateAttachment(attachment: Attachment): Attachment { + const url = attachment.fetchMetadata?.url ?? attachment.url; + const teamId = attachment.fetchMetadata?.teamId; + if (!url) { + return attachment; + } + return { + ...attachment, + fetchData: async () => { + let token: string; + if (teamId) { + const installation = await this.getInstallation(teamId); + if (!installation) { + throw new AuthenticationError( + "slack", + `Installation not found for team ${teamId}` + ); } - : undefined, + token = installation.botToken; + } else { + token = this.getToken(); + } + return this.fetchSlackFile(url, token); + }, }; } @@ -3647,7 +3684,7 @@ export class SlackAdapter implements Adapter { : undefined, }, attachments: (event.files || []).map((file) => - this.createAttachment(file) + this.createAttachment(file, event.team_id ?? event.team) ), links: this.extractLinks(event), }); diff --git a/packages/adapter-teams/src/index.ts b/packages/adapter-teams/src/index.ts index 89925d69..c813d038 100644 --- a/packages/adapter-teams/src/index.ts +++ b/packages/adapter-teams/src/index.ts @@ -778,22 +778,33 @@ export class TeamsAdapter implements Adapter { url, name: att.name, mimeType: att.contentType, - fetchData: url - ? async () => { - const response = await fetch(url); - if (!response.ok) { - throw new NetworkError( - "teams", - `Failed to fetch file: ${response.status} ${response.statusText}` - ); - } - const arrayBuffer = await response.arrayBuffer(); - return Buffer.from(arrayBuffer); - } - : undefined, + fetchMetadata: url ? { url } : undefined, + fetchData: url ? this.createFetchDataFn(url) : undefined, }; } + private createFetchDataFn(url: string): () => Promise { + return async () => { + const response = await fetch(url); + if (!response.ok) { + throw new NetworkError( + "teams", + `Failed to fetch file: ${response.status} ${response.statusText}` + ); + } + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); + }; + } + + rehydrateAttachment(attachment: Attachment): Attachment { + const url = attachment.fetchMetadata?.url ?? attachment.url; + if (!url) { + return attachment; + } + return { ...attachment, fetchData: this.createFetchDataFn(url) }; + } + private normalizeMentions(text: string): string { return text.trim(); } diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index 8ebaf96b..eb0e263d 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -1159,6 +1159,18 @@ export class TelegramAdapter height: metadata?.height, name: metadata?.name, mimeType: metadata?.mimeType, + fetchMetadata: { fileId }, + fetchData: async () => this.downloadFile(fileId), + }; + } + + rehydrateAttachment(attachment: Attachment): Attachment { + const fileId = attachment.fetchMetadata?.fileId; + if (!fileId) { + return attachment; + } + return { + ...attachment, fetchData: async () => this.downloadFile(fileId), }; } diff --git a/packages/adapter-whatsapp/src/index.ts b/packages/adapter-whatsapp/src/index.ts index 29eb5bbb..f0b37fe7 100644 --- a/packages/adapter-whatsapp/src/index.ts +++ b/packages/adapter-whatsapp/src/index.ts @@ -655,6 +655,18 @@ export class WhatsAppAdapter type, mimeType, name, + fetchMetadata: { mediaId }, + fetchData: () => this.downloadMedia(mediaId), + }; + } + + rehydrateAttachment(attachment: Attachment): Attachment { + const mediaId = attachment.fetchMetadata?.mediaId; + if (!mediaId) { + return attachment; + } + return { + ...attachment, fetchData: () => this.downloadMedia(mediaId), }; } diff --git a/packages/chat/src/chat.test.ts b/packages/chat/src/chat.test.ts index 578311d2..a1878c67 100644 --- a/packages/chat/src/chat.test.ts +++ b/packages/chat/src/chat.test.ts @@ -2401,6 +2401,223 @@ describe("Chat", () => { }); }); + describe("concurrency: queue attachment rehydration", () => { + function createJsonRoundtripState() { + const state = createMockState(); + const realEnqueue = state.enqueue.getMockImplementation(); + if (!realEnqueue) { + throw new Error("Expected enqueue to have a mock implementation"); + } + vi.mocked(state.enqueue).mockImplementation( + async (threadId, entry, maxSize) => { + // Simulate real state adapter: JSON.stringify strips functions + const serialized = JSON.parse(JSON.stringify(entry)); + return realEnqueue(threadId, serialized, maxSize); + } + ); + return state; + } + + it("should call rehydrateAttachment on deserialized attachments missing fetchData", async () => { + const state = createJsonRoundtripState(); + const adapter = createMockAdapter("slack"); + const mockFetchData = vi.fn().mockResolvedValue(Buffer.from("data")); + adapter.rehydrateAttachment = vi.fn().mockImplementation((att) => ({ + ...att, + fetchData: mockFetchData, + })); + + const queueChat = new Chat({ + userName: "testbot", + adapters: { slack: adapter }, + state, + logger: mockLogger, + concurrency: "queue", + }); + + await queueChat.webhooks.slack( + new Request("http://test.com", { method: "POST" }) + ); + + const receivedAttachments: unknown[] = []; + queueChat.onNewMention( + vi.fn().mockImplementation(async (_thread, message) => { + receivedAttachments.push(message.attachments); + }) + ); + + // Pre-acquire lock so the message gets enqueued (and JSON-serialized) + await state.acquireLock("slack:C123:1234.5678", 30000); + + const msg = createTestMessage("msg-att-1", "Hey @slack-bot file", { + attachments: [ + { + type: "file" as const, + url: "https://example.com/f.pdf", + name: "f.pdf", + fetchMetadata: { url: "https://example.com/f.pdf" }, + fetchData: () => Promise.resolve(Buffer.from("original")), + }, + ], + }); + + await queueChat.handleIncomingMessage( + adapter, + "slack:C123:1234.5678", + msg + ); + + // Release lock and trigger drain with a new message + await state.forceReleaseLock("slack:C123:1234.5678"); + const trigger = createTestMessage("msg-att-2", "Hey @slack-bot trigger"); + await queueChat.handleIncomingMessage( + adapter, + "slack:C123:1234.5678", + trigger + ); + + // rehydrateAttachment should have been called for the queued message + expect(adapter.rehydrateAttachment).toHaveBeenCalledWith( + expect.objectContaining({ + type: "file", + fetchMetadata: { url: "https://example.com/f.pdf" }, + }) + ); + + // The handler should receive the attachment with fetchData restored + expect(receivedAttachments.length).toBeGreaterThanOrEqual(1); + const queuedAttachments = receivedAttachments.find( + (atts) => + Array.isArray(atts) && atts.length > 0 && atts[0].name === "f.pdf" + ) as { fetchData?: () => Promise }[]; + expect(queuedAttachments).toBeDefined(); + expect(queuedAttachments[0].fetchData).toBe(mockFetchData); + }); + + it("should skip rehydration for attachments that already have fetchData", async () => { + const state = createMockState(); // no JSON roundtrip — Message survives as instance + const adapter = createMockAdapter("slack"); + adapter.rehydrateAttachment = vi.fn(); + + const queueChat = new Chat({ + userName: "testbot", + adapters: { slack: adapter }, + state, + logger: mockLogger, + concurrency: "queue", + }); + + await queueChat.webhooks.slack( + new Request("http://test.com", { method: "POST" }) + ); + + const originalFetchData = vi + .fn() + .mockResolvedValue(Buffer.from("original")); + + const receivedAttachments: unknown[] = []; + queueChat.onNewMention( + vi.fn().mockImplementation(async (_thread, message) => { + receivedAttachments.push(message.attachments); + }) + ); + + await state.acquireLock("slack:C123:1234.5678", 30000); + + const msg = createTestMessage("msg-skip-1", "Hey @slack-bot file", { + attachments: [ + { + type: "file" as const, + url: "https://example.com/f.pdf", + fetchData: originalFetchData, + }, + ], + }); + + await queueChat.handleIncomingMessage( + adapter, + "slack:C123:1234.5678", + msg + ); + + await state.forceReleaseLock("slack:C123:1234.5678"); + const trigger = createTestMessage("msg-skip-2", "Hey @slack-bot trigger"); + await queueChat.handleIncomingMessage( + adapter, + "slack:C123:1234.5678", + trigger + ); + + // rehydrateAttachment should NOT have been called — fetchData was already present + expect(adapter.rehydrateAttachment).not.toHaveBeenCalled(); + }); + + it("should leave attachments unchanged when adapter has no rehydrateAttachment", async () => { + const state = createJsonRoundtripState(); + const adapter = createMockAdapter("slack"); + // adapter has no rehydrateAttachment (default from createMockAdapter) + + const queueChat = new Chat({ + userName: "testbot", + adapters: { slack: adapter }, + state, + logger: mockLogger, + concurrency: "queue", + }); + + await queueChat.webhooks.slack( + new Request("http://test.com", { method: "POST" }) + ); + + const receivedAttachments: unknown[] = []; + queueChat.onNewMention( + vi.fn().mockImplementation(async (_thread, message) => { + receivedAttachments.push(message.attachments); + }) + ); + + await state.acquireLock("slack:C123:1234.5678", 30000); + + const msg = createTestMessage("msg-noop-1", "Hey @slack-bot file", { + attachments: [ + { + type: "file" as const, + url: "https://example.com/f.pdf", + fetchMetadata: { url: "https://example.com/f.pdf" }, + fetchData: () => Promise.resolve(Buffer.from("data")), + }, + ], + }); + + await queueChat.handleIncomingMessage( + adapter, + "slack:C123:1234.5678", + msg + ); + + await state.forceReleaseLock("slack:C123:1234.5678"); + const trigger = createTestMessage("msg-noop-2", "Hey @slack-bot trigger"); + await queueChat.handleIncomingMessage( + adapter, + "slack:C123:1234.5678", + trigger + ); + + // Attachment should still have fetchMetadata but no fetchData (lost in JSON roundtrip) + const queuedAttachments = receivedAttachments.find( + (atts) => + Array.isArray(atts) && + atts.length > 0 && + atts[0].url === "https://example.com/f.pdf" + ) as { fetchData?: unknown; fetchMetadata?: unknown }[]; + expect(queuedAttachments).toBeDefined(); + expect(queuedAttachments[0].fetchMetadata).toEqual({ + url: "https://example.com/f.pdf", + }); + expect(queuedAttachments[0].fetchData).toBeUndefined(); + }); + }); + describe("concurrency: queue with onSubscribedMessage", () => { it("should pass skipped context to subscribed message handlers", async () => { const state = createMockState(); diff --git a/packages/chat/src/chat.ts b/packages/chat/src/chat.ts index c2f289b5..230a3436 100644 --- a/packages/chat/src/chat.ts +++ b/packages/chat/src/chat.ts @@ -1909,7 +1909,7 @@ export class Chat< } // Reconstruct Message instance after JSON roundtrip through state adapter - const msg = this.rehydrateMessage(entry.message); + const msg = this.rehydrateMessage(entry.message, adapter); if (Date.now() > entry.expiresAt) { this.logger.info("message-expired", { @@ -1961,7 +1961,7 @@ export class Chat< if (!entry) { break; } - const msg = this.rehydrateMessage(entry.message); + const msg = this.rehydrateMessage(entry.message, adapter); if (Date.now() <= entry.expiresAt) { pending.push({ message: msg, expiresAt: entry.expiresAt }); } else { @@ -2210,44 +2210,58 @@ export class Chat< * object (not a Message instance). This restores class invariants like * `links` defaulting to `[]` and `metadata.dateSent` being a Date. */ - private rehydrateMessage(raw: Message | Record): Message { + private rehydrateMessage( + raw: Message | Record, + adapter?: Adapter + ): Message { if (raw instanceof Message) { return raw; } // After JSON roundtrip, Message.toJSON() was called during stringify, // so the shape matches SerializedMessage const obj = raw as Record; + let msg: Message; if (obj._type === "chat:Message") { - return Message.fromJSON(obj as unknown as SerializedMessage); - } - // Fallback: plain object that wasn't serialized via toJSON (e.g., in-memory state) - // Reconstruct with defensive defaults - const metadata = obj.metadata as Record; - const dateSent = metadata.dateSent; - const editedAt = metadata.editedAt; - return new Message({ - id: obj.id as string, - threadId: obj.threadId as string, - text: obj.text as string, - formatted: obj.formatted as FormattedContent, - raw: obj.raw, - author: obj.author as Author, - metadata: { - dateSent: - dateSent instanceof Date ? dateSent : new Date(dateSent as string), - edited: metadata.edited as boolean, - editedAt: editedAt - ? new Date( - editedAt instanceof Date - ? editedAt.toISOString() - : (editedAt as string) - ) - : undefined, - }, - attachments: (obj.attachments as Attachment[]) ?? [], - isMention: obj.isMention as boolean | undefined, - links: (obj.links as LinkPreview[] | undefined) ?? [], - }); + msg = Message.fromJSON(obj as unknown as SerializedMessage); + } else { + // Fallback: plain object that wasn't serialized via toJSON (e.g., in-memory state) + // Reconstruct with defensive defaults + const metadata = obj.metadata as Record; + const dateSent = metadata.dateSent; + const editedAt = metadata.editedAt; + msg = new Message({ + id: obj.id as string, + threadId: obj.threadId as string, + text: obj.text as string, + formatted: obj.formatted as FormattedContent, + raw: obj.raw, + author: obj.author as Author, + metadata: { + dateSent: + dateSent instanceof Date ? dateSent : new Date(dateSent as string), + edited: metadata.edited as boolean, + editedAt: editedAt + ? new Date( + editedAt instanceof Date + ? editedAt.toISOString() + : (editedAt as string) + ) + : undefined, + }, + attachments: (obj.attachments as Attachment[]) ?? [], + isMention: obj.isMention as boolean | undefined, + links: (obj.links as LinkPreview[] | undefined) ?? [], + }); + } + + const rehydrate = adapter?.rehydrateAttachment?.bind(adapter); + if (rehydrate && msg.attachments.length > 0) { + msg.attachments = msg.attachments.map((att) => + att.fetchData ? att : rehydrate(att) + ); + } + + return msg; } private async runHandlers( diff --git a/packages/chat/src/message.test.ts b/packages/chat/src/message.test.ts index bce475b0..b10ead7a 100644 --- a/packages/chat/src/message.test.ts +++ b/packages/chat/src/message.test.ts @@ -85,11 +85,61 @@ describe("Message", () => { size: undefined, width: undefined, height: undefined, + fetchMetadata: undefined, }); expect("data" in json.attachments[0]).toBe(false); expect("fetchData" in json.attachments[0]).toBe(false); }); + it("should preserve fetchMetadata in attachments", () => { + const msg = makeMessage({ + attachments: [ + { + type: "image" as const, + url: "https://example.com/img.png", + fetchMetadata: { + mediaId: "123", + url: "https://example.com/img.png", + }, + fetchData: () => Promise.resolve(Buffer.from("binary")), + }, + ], + }); + const json = msg.toJSON(); + expect(json.attachments[0].fetchMetadata).toEqual({ + mediaId: "123", + url: "https://example.com/img.png", + }); + const restored = Message.fromJSON(json); + expect(restored.attachments[0].fetchMetadata).toEqual({ + mediaId: "123", + url: "https://example.com/img.png", + }); + }); + + it("should preserve fetchMetadata through full JSON.stringify/parse roundtrip", () => { + const msg = makeMessage({ + attachments: [ + { + type: "image" as const, + url: "https://example.com/img.png", + fetchMetadata: { + mediaId: "123", + url: "https://example.com/img.png", + }, + fetchData: () => Promise.resolve(Buffer.from("binary")), + }, + ], + }); + const roundtripped = JSON.parse(JSON.stringify(msg.toJSON())); + const restored = Message.fromJSON(roundtripped); + expect(restored.attachments[0].fetchMetadata).toEqual({ + mediaId: "123", + url: "https://example.com/img.png", + }); + expect(restored.attachments[0].fetchData).toBeUndefined(); + }); + it("should include isMention flag", () => { const json = makeMessage({ isMention: true }).toJSON(); expect(json.isMention).toBe(true); diff --git a/packages/chat/src/message.ts b/packages/chat/src/message.ts index 522b656b..fbde4121 100644 --- a/packages/chat/src/message.ts +++ b/packages/chat/src/message.ts @@ -53,6 +53,7 @@ export interface SerializedMessage { size?: number; width?: number; height?: number; + fetchMetadata?: Record; }>; author: { userId: string; @@ -195,6 +196,7 @@ export class Message { size: att.size, width: att.width, height: att.height, + fetchMetadata: att.fetchMetadata, })), isMention: this.isMention, links: diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index 4aa501ad..27c5ef12 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -438,6 +438,13 @@ export interface Adapter { data: unknown ): Promise>; + /** + * Reconstruct fetchData on an attachment after deserialization. + * Called during message rehydration for queue/debounce strategies. + * Uses fetchMetadata and adapter auth context to rebuild the download closure. + */ + rehydrateAttachment?(attachment: Attachment): Attachment; + /** Remove a reaction from a message */ removeReaction( threadId: string, @@ -1474,6 +1481,12 @@ export interface Attachment { * this method handles the auth automatically. */ fetchData?: () => Promise; + /** + * Platform-specific metadata needed to reconstruct fetchData after serialization. + * Adapters store IDs here (e.g. WhatsApp mediaId, Telegram fileId) so that + * fetchData can be rebuilt when a message is rehydrated from the queue. + */ + fetchMetadata?: Record; /** Image/video height (if applicable) */ height?: number; /** MIME type */