feat: add outbound attachment support with media.send tool

Introduces OutboundAttachment type on OutboundMessage, an
OutboundAttachmentCollector (push/drain pattern), and a media.send
tool that queues files for outbound delivery. Each channel adapter
(Telegram, Discord, Slack, WhatsApp) sends attachments after the
text reply. Includes 15 tests for collector and tool.
This commit is contained in:
William Valentin
2026-02-07 09:09:00 -08:00
parent 1e6f6bb5a4
commit b9bfee9c5b
15 changed files with 576 additions and 21 deletions
+29 -4
View File
@@ -6,13 +6,14 @@
* Messages are chunked at Discord's 2000-char limit.
*/
import { Client, GatewayIntentBits, Events } from 'discord.js';
import { Client, GatewayIntentBits, Events, AttachmentBuilder } from 'discord.js';
import type { Message as DiscordMessage } from 'discord.js';
import type {
Attachment,
InboundMessage,
OutboundMessage,
OutboundAttachment,
ChannelAdapter,
ChannelStatus,
} from '../types.js';
@@ -121,7 +122,7 @@ export class DiscordAdapter implements ChannelAdapter {
}
const text = message.text;
const sendable = channel as { send: (content: string) => Promise<unknown> };
const sendable = channel as { send: (content: string | Record<string, unknown>) => Promise<unknown> };
if (text.length <= 2000) {
await sendable.send(text);
@@ -131,6 +132,30 @@ export class DiscordAdapter implements ChannelAdapter {
await sendable.send(chunk);
}
}
// Send outbound attachments after text
if (message.attachments && message.attachments.length > 0) {
const files = message.attachments
.filter((a) => a.data || a.url)
.map((a) => this.buildDiscordAttachment(a));
if (files.length > 0) {
await sendable.send({ files });
}
}
}
/** Build a discord.js AttachmentBuilder from an OutboundAttachment. */
private buildDiscordAttachment(attachment: OutboundAttachment): AttachmentBuilder {
if (attachment.data) {
return new AttachmentBuilder(Buffer.from(attachment.data, 'base64'), {
name: attachment.filename ?? 'attachment',
});
}
// URL-based attachment
return new AttachmentBuilder(attachment.url!, {
name: attachment.filename ?? 'attachment',
});
}
/** Internal: process an inbound Discord message. */
@@ -174,12 +199,12 @@ export class DiscordAdapter implements ChannelAdapter {
// Strip bot mention from the message text
const text = message.content.replace(/<@!?\d+>/g, '').trim();
// ── Extract image attachments ──
// ── Extract media attachments ──
const attachments: Attachment[] = [];
if (message.attachments && message.attachments.size > 0) {
for (const attachment of message.attachments.values()) {
const mimeType = attachment.contentType || this._inferMimeTypeFromUrl(attachment.url);
if (mimeType && mimeType.startsWith('image/')) {
if (mimeType && (mimeType.startsWith('image/') || mimeType.startsWith('audio/'))) {
attachments.push({
mimeType,
url: attachment.url,