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,
+1
View File
@@ -3,6 +3,7 @@ export type {
ChannelStatus,
InboundMessage,
OutboundMessage,
OutboundAttachment,
Attachment,
ToolStatusEvent,
MessageHandler,
+47 -7
View File
@@ -11,6 +11,7 @@ import type {
Attachment,
InboundMessage,
OutboundMessage,
OutboundAttachment,
ChannelAdapter,
ChannelStatus,
} from '../types.js';
@@ -152,6 +153,45 @@ export class SlackAdapter implements ChannelAdapter {
});
}
}
// Send outbound attachments after text
if (message.attachments && message.attachments.length > 0) {
for (const attachment of message.attachments) {
await this.sendAttachment(channel, threadTs, attachment);
}
}
}
/** Upload and send a single outbound attachment via Slack's files.uploadV2 API. */
private async sendAttachment(
channel: string,
threadTs: string,
attachment: OutboundAttachment,
): Promise<void> {
if (!this.app) return;
try {
if (attachment.data) {
await this.app.client.files.uploadV2({
channel_id: channel,
thread_ts: threadTs,
file: Buffer.from(attachment.data, 'base64'),
filename: attachment.filename ?? 'attachment',
});
} else if (attachment.url) {
// For URL-based attachments, share as a text message with the URL
await this.app.client.chat.postMessage({
channel,
text: attachment.url,
thread_ts: threadTs,
});
}
} catch (error) {
console.error(
`Slack: failed to send ${attachment.mimeType} attachment:`,
error instanceof Error ? error.message : 'Unknown error',
);
}
}
/** Resolve a Slack user ID to a display name, with caching. */
@@ -170,10 +210,10 @@ export class SlackAdapter implements ChannelAdapter {
}
/**
* Download image files from a Slack message and convert to base64 Attachments.
* Non-image files are skipped. Download errors are logged but don't crash the handler.
* Download media files from a Slack message and convert to base64 Attachments.
* Non-media files are skipped. Download errors are logged but don't crash the handler.
*/
private async extractImageAttachments(
private async extractMediaAttachments(
files?: SlackMessageEvent['files'],
): Promise<Attachment[]> {
if (!files || files.length === 0) return [];
@@ -181,8 +221,8 @@ export class SlackAdapter implements ChannelAdapter {
const attachments: Attachment[] = [];
for (const file of files) {
// Only process image files
if (!file.mimetype?.startsWith('image/')) continue;
// Only process image and audio files
if (!file.mimetype?.startsWith('image/') && !file.mimetype?.startsWith('audio/')) continue;
const downloadUrl = file.url_private_download || file.url_private;
if (!downloadUrl) continue;
@@ -259,8 +299,8 @@ export class SlackAdapter implements ChannelAdapter {
? await this.resolveUserName(message.user)
: undefined;
// Extract image attachments from Slack file uploads
const attachments = await this.extractImageAttachments(message.files);
// Extract media attachments from Slack file uploads
const attachments = await this.extractMediaAttachments(message.files);
// Detect reset command
if (text === '!reset' || text === 'reset') {
+105 -1
View File
@@ -1,10 +1,11 @@
import { Bot } from 'grammy';
import { Bot, InputFile } from 'grammy';
import type { HookEngine } from '../../hooks/index.js';
import type {
Attachment,
InboundMessage,
OutboundMessage,
OutboundAttachment,
ChannelAdapter,
ChannelStatus,
} from '../types.js';
@@ -263,6 +264,80 @@ export class TelegramAdapter implements ChannelAdapter {
});
});
// ── Voice message handler ──
this.bot.on('message:voice', async (ctx) => {
if (!this.messageHandler) return;
const voice = ctx.message.voice;
if (!voice) return;
await ctx.replyWithChatAction('typing');
const fileData = await this.downloadFileToBase64(voice.file_id);
if (!fileData) {
console.error(`Failed to download voice message ${voice.file_id}`);
return;
}
const caption = ctx.message.caption ?? '';
const mimeType = voice.mime_type ?? 'audio/ogg';
this.messageHandler({
id: String(ctx.message.message_id),
channel: 'telegram',
senderId: String(ctx.chat.id),
senderName: ctx.from?.first_name,
text: caption,
attachments: [
{
mimeType,
data: fileData,
filename: `voice_${voice.file_unique_id}.ogg`,
size: voice.file_size,
},
],
timestamp: Date.now(),
});
});
// ── Audio message handler ──
this.bot.on('message:audio', async (ctx) => {
if (!this.messageHandler) return;
const audio = ctx.message.audio;
if (!audio) return;
await ctx.replyWithChatAction('typing');
const fileData = await this.downloadFileToBase64(audio.file_id);
if (!fileData) {
console.error(`Failed to download audio message ${audio.file_id}`);
return;
}
const caption = ctx.message.caption ?? '';
const mimeType = audio.mime_type ?? 'audio/mpeg';
this.messageHandler({
id: String(ctx.message.message_id),
channel: 'telegram',
senderId: String(ctx.chat.id),
senderName: ctx.from?.first_name,
text: caption,
attachments: [
{
mimeType,
data: fileData,
filename: `audio_${audio.file_unique_id}.${mimeType.split('/')[1]}`,
size: audio.file_size,
},
],
timestamp: Date.now(),
});
});
// ── Start long polling ──
this.bot.start({
@@ -304,5 +379,34 @@ export class TelegramAdapter implements ChannelAdapter {
await this.bot.api.sendMessage(chatId, chunk, { parse_mode: 'Markdown' });
}
}
// Send outbound attachments after text
if (message.attachments && message.attachments.length > 0) {
for (const attachment of message.attachments) {
await this.sendAttachment(chatId, attachment);
}
}
}
/** Send a single outbound attachment via the Telegram API. */
private async sendAttachment(chatId: number, attachment: OutboundAttachment): Promise<void> {
if (!this.bot) return;
try {
const file = attachment.data
? new InputFile(Buffer.from(attachment.data, 'base64'), attachment.filename)
: attachment.url ?? '';
if (attachment.mimeType.startsWith('image/')) {
await this.bot.api.sendPhoto(chatId, file);
} else {
await this.bot.api.sendDocument(chatId, file);
}
} catch (error) {
console.error(
`Failed to send ${attachment.mimeType} attachment to ${chatId}:`,
error instanceof Error ? error.message : 'Unknown error',
);
}
}
}
+14
View File
@@ -42,12 +42,26 @@ export interface InboundMessage {
metadata?: Record<string, unknown>;
}
/** Attachment to send back via a channel adapter. */
export interface OutboundAttachment {
/** MIME type (e.g. "image/png", "application/pdf"). */
mimeType: string;
/** Base64-encoded file content. */
data?: string;
/** URL to the file (alternative to data). */
url?: string;
/** Suggested filename. */
filename?: string;
}
/** Outbound message to send via a channel adapter. */
export interface OutboundMessage {
/** Response text (markdown). */
text: string;
/** Original message ID to reply to. */
replyTo?: string;
/** File or image attachments to send with the response. */
attachments?: OutboundAttachment[];
/** Platform-specific extras. */
metadata?: Record<string, unknown>;
}
+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(