Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/better-eagles-serve.md
Original file line number Diff line number Diff line change
@@ -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
6 changes: 5 additions & 1 deletion apps/docs/content/docs/api/message.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Buffer> | undefined',
},
fetchMetadata: {
description: 'Platform-specific IDs for reconstructing fetchData after serialization (e.g. WhatsApp mediaId, Telegram fileId).',
type: 'Record<string, string> | undefined',
},
}}
/>

Expand Down Expand Up @@ -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.
1 change: 1 addition & 0 deletions apps/docs/content/docs/files.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,4 @@ bot.onSubscribedMessage(async (thread, message) => {
| `width` | `number` (optional) | Image width |
| `height` | `number` (optional) | Image height |
| `fetchData` | `() => Promise<Buffer>` (optional) | Download the file data |
| `fetchMetadata` | `Record<string, string>` (optional) | Platform-specific IDs for reconstructing `fetchData` after serialization |
113 changes: 67 additions & 46 deletions packages/adapter-gchat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1449,9 +1449,7 @@ export class GoogleChatAdapter implements Adapter<GoogleChatThreadId, unknown> {
}): 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";
Expand All @@ -1461,62 +1459,85 @@ export class GoogleChatAdapter implements Adapter<GoogleChatThreadId, unknown> {
type = "audio";
}

// Capture auth client for use in fetchData closure (used for URL fallback)
const auth = this.authClient;
const fetchMeta: Record<string, string> = {};
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<Buffer> {
// 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,
Expand Down
59 changes: 59 additions & 0 deletions packages/adapter-slack/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
109 changes: 73 additions & 36 deletions packages/adapter-slack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1963,7 +1963,7 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
: undefined,
},
attachments: (event.files || []).map((file) =>
this.createAttachment(file)
this.createAttachment(file, event.team_id ?? event.team)
),
links: this.extractLinks(event),
});
Expand All @@ -1973,15 +1973,18 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
* 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();
Expand All @@ -1996,6 +1999,14 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
type = "audio";
}

const fetchMeta: Record<string, string> = {};
if (url) {
fetchMeta.url = url;
}
if (teamId) {
fetchMeta.teamId = teamId;
}

return {
type,
url,
Expand All @@ -2004,32 +2015,58 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
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<Buffer> {
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);
},
};
}

Expand Down Expand Up @@ -3647,7 +3684,7 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
: undefined,
},
attachments: (event.files || []).map((file) =>
this.createAttachment(file)
this.createAttachment(file, event.team_id ?? event.team)
),
links: this.extractLinks(event),
});
Expand Down
Loading
Loading