From b2eb3da0be077169c7b464f0954bb6e3c7318a81 Mon Sep 17 00:00:00 2001 From: dancer Date: Mon, 6 Apr 2026 20:28:02 +0100 Subject: [PATCH 01/10] feat(chat): add fetchMetadata to Attachment and rehydrateAttachment to Adapter --- packages/chat/src/message.ts | 2 ++ packages/chat/src/types.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+) 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 */ From e6ea2ba2f5bd9e501c0dc69c1728e7ab7cbe6395 Mon Sep 17 00:00:00 2001 From: dancer Date: Mon, 6 Apr 2026 20:28:03 +0100 Subject: [PATCH 02/10] feat(chat): restore fetchData on attachments during message rehydration --- packages/chat/src/chat.ts | 79 +++++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 33 deletions(-) diff --git a/packages/chat/src/chat.ts b/packages/chat/src/chat.ts index c2f289b5..0c065994 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,57 @@ 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) ?? [], + }); + } + + if (adapter?.rehydrateAttachment && msg.attachments.length > 0) { + msg.attachments = msg.attachments.map((att) => + att.fetchData ? att : (adapter.rehydrateAttachment?.(att) ?? att) + ); + } + + return msg; } private async runHandlers( From 2e9d954605edb42d97cca1b793f37c12c14044fd Mon Sep 17 00:00:00 2001 From: dancer Date: Mon, 6 Apr 2026 20:28:05 +0100 Subject: [PATCH 03/10] feat(adapters): implement rehydrateAttachment and store fetchMetadata --- packages/adapter-gchat/src/index.ts | 111 +++++++++++++++---------- packages/adapter-slack/src/index.ts | 37 +++++++++ packages/adapter-teams/src/index.ts | 37 ++++++--- packages/adapter-telegram/src/index.ts | 12 +++ packages/adapter-whatsapp/src/index.ts | 12 +++ 5 files changed, 150 insertions(+), 59 deletions(-) diff --git a/packages/adapter-gchat/src/index.ts b/packages/adapter-gchat/src/index.ts index 743b2802..1bebdabf 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,83 @@ 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 { + if (resourceName) { + const res = await this.chatApi.media.download( + { resourceName }, + { responseType: "arraybuffer" } + ); + return Buffer.from(res.data as ArrayBuffer); + } + + 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.ts b/packages/adapter-slack/src/index.ts index d523d695..8f4e8680 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -2004,6 +2004,7 @@ export class SlackAdapter implements Adapter { size: file.size, width: file.original_w, height: file.original_h, + fetchMetadata: url ? { url } : undefined, fetchData: url ? async () => { const response = await fetch(url, { @@ -2033,6 +2034,42 @@ export class SlackAdapter implements Adapter { }; } + private fetchSlackFile(url: string, token: string): Promise { + return (async () => { + 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; + if (!url) { + return attachment; + } + return { + ...attachment, + fetchData: () => this.fetchSlackFile(url, this.getToken()), + }; + } + /** * Resolve @name mentions in text to Slack <@USER_ID> format using the * reverse user cache. When multiple users share a display name, prefers 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), }; } From 57f2064def5633e8f930895b5f0422b3d7afb58c Mon Sep 17 00:00:00 2001 From: dancer Date: Mon, 6 Apr 2026 20:28:06 +0100 Subject: [PATCH 04/10] test(chat): add fetchMetadata serialization round-trip test --- packages/chat/src/message.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/chat/src/message.test.ts b/packages/chat/src/message.test.ts index bce475b0..a8969c24 100644 --- a/packages/chat/src/message.test.ts +++ b/packages/chat/src/message.test.ts @@ -85,11 +85,38 @@ 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 include isMention flag", () => { const json = makeMessage({ isMention: true }).toJSON(); expect(json.isMention).toBe(true); From 0bd7365300b36602b0c559630a22b36d83bfbc51 Mon Sep 17 00:00:00 2001 From: dancer Date: Mon, 6 Apr 2026 20:28:07 +0100 Subject: [PATCH 05/10] docs: add fetchMetadata to attachment reference --- apps/docs/content/docs/api/message.mdx | 6 +++++- apps/docs/content/docs/files.mdx | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) 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 | From c524dd4c1b4b8b374fbaaca6b4feaab106875021 Mon Sep 17 00:00:00 2001 From: dancer Date: Mon, 6 Apr 2026 20:34:00 +0100 Subject: [PATCH 06/10] chore: add changeset --- .changeset/better-eagles-serve.md | 10 ++++++++++ packages/adapter-gchat/src/index.ts | 2 ++ 2 files changed, 12 insertions(+) create mode 100644 .changeset/better-eagles-serve.md 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/packages/adapter-gchat/src/index.ts b/packages/adapter-gchat/src/index.ts index 1bebdabf..8721b89e 100644 --- a/packages/adapter-gchat/src/index.ts +++ b/packages/adapter-gchat/src/index.ts @@ -1484,6 +1484,7 @@ export class GoogleChatAdapter implements Adapter { resourceName?: string, url?: string ): Promise { + // Prefer media.download API (correct method for chat apps) if (resourceName) { const res = await this.chatApi.media.download( { resourceName }, @@ -1492,6 +1493,7 @@ export class GoogleChatAdapter implements Adapter { return Buffer.from(res.data as ArrayBuffer); } + // Fallback to direct URL fetch (downloadUri) if (!url) { throw new NetworkError("gchat", "No URL or resourceName available"); } From 18c67cc0e5ab5f3bdea359d8dcf405b8e691bac9 Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Fri, 10 Apr 2026 09:33:19 +1000 Subject: [PATCH 07/10] refactor: deduplicate Slack fetchSlackFile and clean up rehydration - Slack createAttachment now calls fetchSlackFile instead of duplicating fetch logic inline - Remove unnecessary IIFE wrapper in fetchSlackFile - Fix redundant optional chain in rehydrateMessage by extracting bound method --- packages/adapter-slack/src/index.ts | 71 +++++++++-------------------- packages/chat/src/chat.ts | 5 +- 2 files changed, 25 insertions(+), 51 deletions(-) diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index 8f4e8680..fd184ee3 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -2005,58 +2005,31 @@ export class SlackAdapter implements Adapter { width: file.original_w, height: file.original_h, fetchMetadata: url ? { url } : undefined, - 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); - } - : undefined, + fetchData: url ? () => this.fetchSlackFile(url, botToken) : undefined, }; } - private fetchSlackFile(url: string, token: string): Promise { - return (async () => { - 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); - })(); + 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 { diff --git a/packages/chat/src/chat.ts b/packages/chat/src/chat.ts index 0c065994..230a3436 100644 --- a/packages/chat/src/chat.ts +++ b/packages/chat/src/chat.ts @@ -2254,9 +2254,10 @@ export class Chat< }); } - if (adapter?.rehydrateAttachment && msg.attachments.length > 0) { + const rehydrate = adapter?.rehydrateAttachment?.bind(adapter); + if (rehydrate && msg.attachments.length > 0) { msg.attachments = msg.attachments.map((att) => - att.fetchData ? att : (adapter.rehydrateAttachment?.(att) ?? att) + att.fetchData ? att : rehydrate(att) ); } From 786f7698c127ea0922c7773fc3ef12c35aae8f40 Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Fri, 10 Apr 2026 09:38:50 +1000 Subject: [PATCH 08/10] test: add fetchMetadata rehydration tests - Full JSON.stringify/parse roundtrip test for fetchMetadata in message.test.ts - Queue drain test: rehydrateAttachment called on deserialized attachments - Queue drain test: skip rehydration when fetchData already present - Queue drain test: attachments unchanged when adapter has no rehydrateAttachment --- packages/chat/src/chat.test.ts | 217 ++++++++++++++++++++++++++++++ packages/chat/src/message.test.ts | 23 ++++ 2 files changed, 240 insertions(+) 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/message.test.ts b/packages/chat/src/message.test.ts index a8969c24..b10ead7a 100644 --- a/packages/chat/src/message.test.ts +++ b/packages/chat/src/message.test.ts @@ -117,6 +117,29 @@ describe("Message", () => { }); }); + 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); From d3c09449a94ffed29d6e6d69333567d7b944571e Mon Sep 17 00:00:00 2001 From: dancer Date: Tue, 14 Apr 2026 05:12:09 +0100 Subject: [PATCH 09/10] fix(slack): store teamId in fetchMetadata for multi-workspace rehydration --- packages/adapter-slack/src/index.ts | 53 ++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index fd184ee3..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,7 +2015,7 @@ export class SlackAdapter implements Adapter { size: file.size, width: file.original_w, height: file.original_h, - fetchMetadata: url ? { url } : undefined, + fetchMetadata: Object.keys(fetchMeta).length > 0 ? fetchMeta : undefined, fetchData: url ? () => this.fetchSlackFile(url, botToken) : undefined, }; } @@ -2034,12 +2045,28 @@ export class SlackAdapter implements Adapter { rehydrateAttachment(attachment: Attachment): Attachment { const url = attachment.fetchMetadata?.url ?? attachment.url; + const teamId = attachment.fetchMetadata?.teamId; if (!url) { return attachment; } return { ...attachment, - fetchData: () => this.fetchSlackFile(url, this.getToken()), + 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}` + ); + } + token = installation.botToken; + } else { + token = this.getToken(); + } + return this.fetchSlackFile(url, token); + }, }; } @@ -3657,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), }); From 0802094a86e859a2bfbd57ef1534a32bfd0cab73 Mon Sep 17 00:00:00 2001 From: dancer Date: Tue, 14 Apr 2026 05:12:10 +0100 Subject: [PATCH 10/10] test(slack): add rehydrateAttachment tests for multi-workspace and single-workspace --- packages/adapter-slack/src/index.test.ts | 59 ++++++++++++++++++++++++ 1 file changed, 59 insertions(+) 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); + }); + }); });