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