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
+48 -8
View File
@@ -7,11 +7,12 @@
* Messages are chunked at 4096 chars (same as Telegram).
*/
import { Client, LocalAuth } from 'whatsapp-web.js';
import { Client, LocalAuth, MessageMedia } from 'whatsapp-web.js';
import type {
Attachment,
InboundMessage,
OutboundMessage,
OutboundAttachment,
ChannelAdapter,
ChannelStatus,
} from '../types.js';
@@ -153,6 +154,38 @@ export class WhatsAppAdapter implements ChannelAdapter {
await this.client.sendMessage(peerId, chunk);
}
}
// Send outbound attachments after text
if (message.attachments && message.attachments.length > 0) {
for (const attachment of message.attachments) {
await this.sendAttachment(peerId, attachment);
}
}
}
/** Send a single outbound attachment via WhatsApp using MessageMedia. */
private async sendAttachment(peerId: string, attachment: OutboundAttachment): Promise<void> {
if (!this.client) return;
try {
if (attachment.data) {
const media = new MessageMedia(
attachment.mimeType,
attachment.data,
attachment.filename,
);
await this.client.sendMessage(peerId, media);
} else if (attachment.url) {
// Download from URL and send as MessageMedia
const media = await MessageMedia.fromUrl(attachment.url);
await this.client.sendMessage(peerId, media);
}
} catch (error) {
console.error(
`WhatsApp: failed to send ${attachment.mimeType} attachment:`,
error instanceof Error ? error.message : 'Unknown error',
);
}
}
/** Internal: process an inbound WhatsApp message. */
@@ -211,17 +244,24 @@ export class WhatsAppAdapter implements ChannelAdapter {
const senderName = message._data?.notifyName;
// Extract image attachments if the message has media
// Extract media attachments if the message has media
const attachments: Attachment[] = [];
if (message.hasMedia) {
try {
const media = await (message as any).downloadMedia();
if (media && typeof media.mimetype === 'string' && media.mimetype.startsWith('image/')) {
attachments.push({
mimeType: media.mimetype,
data: media.data,
filename: media.filename,
});
if (media && typeof media.mimetype === 'string') {
const mimeType = media.mimetype;
const isAudio = mimeType.startsWith('audio/');
const isImage = mimeType.startsWith('image/');
const isVoice = message.type === 'ptt';
if (isAudio || isImage || isVoice) {
attachments.push({
mimeType: mimeType,
data: media.data,
filename: media.filename,
});
}
}
} catch (error) {
console.error(