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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user