Skip to content

Commit 8e8dbad

Browse files
authored
Merge pull request #44 from arvoreeducacao/joao-barros-/-slack-send-audio-image
feat(slack-advanced): send_audio e send_image com TTS
2 parents b0f6b5d + 4050d38 commit 8e8dbad

6 files changed

Lines changed: 350 additions & 3 deletions

File tree

packages/slack-advanced/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@arvoretech/slack-advanced-mcp",
3-
"version": "1.2.0",
3+
"version": "1.3.0",
44
"description": "Advanced Slack MCP Server with semantic user search, smart DMs, style analysis, thread extraction, audio transcription, and image analysis",
55
"main": "dist/index.js",
66
"type": "module",

packages/slack-advanced/src/elevenlabs-client.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import { SlackAdvancedMCPError } from "./types.js";
44

55
export class ElevenLabsSTTClient {
66
private readonly client: ElevenLabsClient;
7+
private readonly defaultVoiceId: string;
78

8-
constructor(apiKey: string) {
9+
constructor(apiKey: string, defaultVoiceId?: string) {
910
this.client = new ElevenLabsClient({ apiKey });
11+
this.defaultVoiceId = defaultVoiceId ?? "JBFqnCBsd6RMkjVDRZzb";
1012
}
1113

1214
async transcribe(
@@ -36,6 +38,50 @@ export class ElevenLabsSTTClient {
3638
}
3739
}
3840

41+
async textToSpeech(params: {
42+
text: string;
43+
voiceId?: string;
44+
modelId?: string;
45+
languageCode?: string;
46+
outputFormat?: string;
47+
}): Promise<Buffer> {
48+
try {
49+
const voiceId = params.voiceId ?? this.defaultVoiceId;
50+
const modelId = params.modelId ?? "eleven_multilingual_v2";
51+
52+
const response = await this.client.textToSpeech.convert(voiceId, {
53+
text: params.text,
54+
modelId,
55+
languageCode: params.languageCode,
56+
outputFormat: (params.outputFormat ?? "mp3_44100_128") as never,
57+
});
58+
59+
const reader = response.getReader();
60+
const chunks: Uint8Array[] = [];
61+
62+
while (true) {
63+
const { done, value } = await reader.read();
64+
if (done) break;
65+
chunks.push(value);
66+
}
67+
68+
const totalLength = chunks.reduce((acc, c) => acc + c.length, 0);
69+
const result = new Uint8Array(totalLength);
70+
let offset = 0;
71+
for (const chunk of chunks) {
72+
result.set(chunk, offset);
73+
offset += chunk.length;
74+
}
75+
76+
return Buffer.from(result);
77+
} catch (error) {
78+
throw new SlackAdvancedMCPError(
79+
`ElevenLabs TTS failed: ${error instanceof Error ? error.message : "unknown"}`,
80+
"ELEVENLABS_TTS_ERROR"
81+
);
82+
}
83+
}
84+
3985
private getContentType(filename: string): string {
4086
const ext = filename.split(".").pop()?.toLowerCase();
4187
const types: Record<string, string> = {

packages/slack-advanced/src/server.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { StyleAnalysisTools } from "./tools/style-analysis.js";
88
import { ThreadTools } from "./tools/threads.js";
99
import { AudioTools } from "./tools/audio.js";
1010
import { ImageTools } from "./tools/images.js";
11+
import { UploadTools } from "./tools/uploads.js";
1112
import {
1213
SearchUsersParamsSchema,
1314
GetUserProfileParamsSchema,
@@ -19,6 +20,8 @@ import {
1920
AnalyzeImageParamsSchema,
2021
GetFileInfoParamsSchema,
2122
SendChannelMessageParamsSchema,
23+
SendAudioParamsSchema,
24+
SendImageParamsSchema,
2225
} from "./types.js";
2326

2427
export class SlackAdvancedMCPServer {
@@ -29,6 +32,7 @@ export class SlackAdvancedMCPServer {
2932
private readonly threadTools: ThreadTools;
3033
private readonly audioTools: AudioTools;
3134
private readonly imageTools: ImageTools;
35+
private readonly uploadTools: UploadTools;
3236

3337
constructor() {
3438
const slackToken = process.env.SLACK_USER_TOKEN;
@@ -48,7 +52,8 @@ export class SlackAdvancedMCPServer {
4852
);
4953

5054
const elevenLabsKey = process.env.ELEVENLABS_API_KEY;
51-
const elevenlabs = elevenLabsKey ? new ElevenLabsSTTClient(elevenLabsKey) : null;
55+
const defaultVoiceId = process.env.ELEVENLABS_DEFAULT_VOICE_ID;
56+
const elevenlabs = elevenLabsKey ? new ElevenLabsSTTClient(elevenLabsKey, defaultVoiceId) : null;
5257

5358
if (!elevenLabsKey) {
5459
console.error("⚠️ ELEVENLABS_API_KEY not set — audio transcription will be unavailable");
@@ -60,6 +65,7 @@ export class SlackAdvancedMCPServer {
6065
this.threadTools = new ThreadTools(slack);
6166
this.audioTools = new AudioTools(slack, elevenlabs);
6267
this.imageTools = new ImageTools(slack);
68+
this.uploadTools = new UploadTools(slack, elevenlabs);
6369

6470
this.setupTools();
6571
}
@@ -154,6 +160,24 @@ export class SlackAdvancedMCPServer {
154160
}, async (params) => {
155161
return this.imageTools.getFileInfo(GetFileInfoParamsSchema.parse(params));
156162
});
163+
164+
this.server.registerTool("send_audio", {
165+
title: "Send Audio",
166+
description:
167+
"Send an audio message to a Slack user (DM) or channel. Can generate speech from text using ElevenLabs TTS (pass 'text' param) or upload an existing audio file (pass file_path or file_base64). Supports thread replies.",
168+
inputSchema: SendAudioParamsSchema.shape,
169+
}, async (params) => {
170+
return this.uploadTools.sendAudio(SendAudioParamsSchema.parse(params));
171+
});
172+
173+
this.server.registerTool("send_image", {
174+
title: "Send Image",
175+
description:
176+
"Upload and send an image to a Slack user (DM) or channel. Accepts a file path on disk or base64-encoded content. Supports thread replies.",
177+
inputSchema: SendImageParamsSchema.shape,
178+
}, async (params) => {
179+
return this.uploadTools.sendImage(SendImageParamsSchema.parse(params));
180+
});
157181
}
158182

159183
async start(): Promise<void> {

packages/slack-advanced/src/slack-client.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,60 @@ export class SlackClient {
268268
return res.file;
269269
}
270270

271+
async uploadFile(params: {
272+
channelId: string;
273+
fileBuffer: Buffer;
274+
filename: string;
275+
initialComment?: string;
276+
threadTs?: string;
277+
}): Promise<{ fileId: string; permalink: string }> {
278+
const { channelId, fileBuffer, filename, initialComment, threadTs } = params;
279+
280+
const uploadUrlRes = await this.request<{
281+
ok: boolean;
282+
upload_url: string;
283+
file_id: string;
284+
}>("files.getUploadURLExternal", {
285+
filename,
286+
length: fileBuffer.length,
287+
});
288+
289+
const uploadRes = await fetch(uploadUrlRes.upload_url, {
290+
method: "POST",
291+
body: new Uint8Array(fileBuffer),
292+
});
293+
294+
if (!uploadRes.ok) {
295+
throw new SlackAdvancedMCPError(
296+
`Failed to upload file to Slack (${uploadRes.status})`,
297+
"FILE_UPLOAD_ERROR",
298+
uploadRes.status
299+
);
300+
}
301+
302+
const fileObj: Record<string, unknown> = { id: uploadUrlRes.file_id };
303+
if (initialComment) fileObj.title = filename;
304+
305+
const completeParams: Record<string, unknown> = {
306+
files: JSON.stringify([fileObj]),
307+
channel_id: channelId,
308+
};
309+
310+
if (initialComment) completeParams.initial_comment = initialComment;
311+
if (threadTs) completeParams.thread_ts = threadTs;
312+
313+
const completeRes = await this.request<{
314+
ok: boolean;
315+
files: Array<{ id: string; permalink: string }>;
316+
}>("files.completeUploadExternal", completeParams);
317+
318+
const file = completeRes.files?.[0];
319+
return {
320+
fileId: file?.id ?? uploadUrlRes.file_id,
321+
permalink: file?.permalink ?? "",
322+
};
323+
}
324+
271325
parseThreadLink(url: string): { channelId: string; threadTs: string } | null {
272326
const match = url.match(/archives\/([A-Z0-9]+)\/p(\d+)/);
273327
if (!match) return null;
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { readFileSync } from "node:fs";
2+
import { SlackClient } from "../slack-client.js";
3+
import { ElevenLabsSTTClient } from "../elevenlabs-client.js";
4+
import type {
5+
SendAudioParams,
6+
SendImageParams,
7+
McpToolResult,
8+
} from "../types.js";
9+
import { SlackAdvancedMCPError } from "../types.js";
10+
11+
export class UploadTools {
12+
constructor(
13+
private readonly slack: SlackClient,
14+
private readonly elevenlabs: ElevenLabsSTTClient | null
15+
) {}
16+
17+
async sendAudio(params: SendAudioParams): Promise<McpToolResult> {
18+
try {
19+
if (params.text) {
20+
return this.generateAndSend(params);
21+
}
22+
return this.uploadAndSend(params, "audio");
23+
} catch (error) {
24+
return this.formatError(error);
25+
}
26+
}
27+
28+
async sendImage(params: SendImageParams): Promise<McpToolResult> {
29+
try {
30+
return this.uploadAndSend(params, "image");
31+
} catch (error) {
32+
return this.formatError(error);
33+
}
34+
}
35+
36+
private async generateAndSend(params: SendAudioParams): Promise<McpToolResult> {
37+
if (!this.elevenlabs) {
38+
return this.ok({
39+
error: "ELEVENLABS_API_KEY not configured. TTS audio generation is unavailable.",
40+
});
41+
}
42+
43+
const audioBuffer = await this.elevenlabs.textToSpeech({
44+
text: params.text!,
45+
voiceId: params.voice_id,
46+
languageCode: params.language_code,
47+
});
48+
49+
const channelId = await this.resolveTarget(params.target, params.target_type);
50+
51+
const filename = params.filename === "audio.mp3" ? "voice-message.mp3" : params.filename;
52+
53+
const result = await this.slack.uploadFile({
54+
channelId,
55+
fileBuffer: audioBuffer,
56+
filename,
57+
initialComment: params.message,
58+
threadTs: params.thread_ts,
59+
});
60+
61+
return this.ok({
62+
sent: true,
63+
type: "tts_audio",
64+
channel: channelId,
65+
target: params.target,
66+
target_type: params.target_type,
67+
file_id: result.fileId,
68+
permalink: result.permalink,
69+
filename,
70+
size_bytes: audioBuffer.length,
71+
tts_text: params.text,
72+
voice_id: params.voice_id ?? "default",
73+
});
74+
}
75+
76+
private async uploadAndSend(
77+
params: SendAudioParams | SendImageParams,
78+
type: "audio" | "image"
79+
): Promise<McpToolResult> {
80+
if (!params.file_path && !params.file_base64) {
81+
return this.ok({ error: "Either text (for TTS), file_path, or file_base64 is required" });
82+
}
83+
84+
let fileBuffer: Buffer;
85+
86+
if (params.file_base64) {
87+
fileBuffer = Buffer.from(params.file_base64, "base64");
88+
} else {
89+
try {
90+
fileBuffer = readFileSync(params.file_path!);
91+
} catch (err) {
92+
return this.ok({
93+
error: `Failed to read file: ${err instanceof Error ? err.message : String(err)}`,
94+
});
95+
}
96+
}
97+
98+
const channelId = await this.resolveTarget(params.target, params.target_type);
99+
100+
const result = await this.slack.uploadFile({
101+
channelId,
102+
fileBuffer,
103+
filename: params.filename,
104+
initialComment: params.message,
105+
threadTs: params.thread_ts,
106+
});
107+
108+
return this.ok({
109+
sent: true,
110+
type,
111+
channel: channelId,
112+
target: params.target,
113+
target_type: params.target_type,
114+
file_id: result.fileId,
115+
permalink: result.permalink,
116+
filename: params.filename,
117+
size_bytes: fileBuffer.length,
118+
});
119+
}
120+
121+
private async resolveTarget(target: string, targetType: "user" | "channel"): Promise<string> {
122+
if (targetType === "user") {
123+
const userId = await this.slack.resolveUserId(target);
124+
return this.slack.openDm(userId);
125+
}
126+
return this.slack.resolveChannelId(target);
127+
}
128+
129+
private ok(data: unknown): McpToolResult {
130+
return {
131+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
132+
};
133+
}
134+
135+
private formatError(error: unknown): McpToolResult {
136+
const message =
137+
error instanceof SlackAdvancedMCPError
138+
? `Slack Error: ${error.message}`
139+
: error instanceof Error
140+
? `Unexpected error: ${error.message}`
141+
: "Unexpected error: Unknown error";
142+
143+
return {
144+
content: [{ type: "text", text: JSON.stringify({ error: message }, null, 2) }],
145+
};
146+
}
147+
}

0 commit comments

Comments
 (0)