Discord: refine voice message handling

This commit is contained in:
Shadow
2026-02-13 12:33:45 -06:00
committed by Shadow
parent 76ab377a19
commit 1c9c01ff49
5 changed files with 93 additions and 39 deletions
+16
View File
@@ -393,6 +393,22 @@ Default gate behavior:
| moderation | disabled | | moderation | disabled |
| presence | disabled | | presence | disabled |
## Voice messages
Discord voice messages show a waveform preview and require OGG/Opus audio plus metadata. OpenClaw generates the waveform automatically, but it needs `ffmpeg` and `ffprobe` available on the gateway host to inspect and convert audio files.
Requirements and constraints:
- Provide a **local file path** (URLs are rejected).
- Omit text content (Discord does not allow text + voice message in the same payload).
- Any audio format is accepted; OpenClaw converts to OGG/Opus when needed.
Example:
```bash
message(action="send", channel="discord", target="channel:123", path="/path/to/audio.mp3", asVoice=true)
```
## Troubleshooting ## Troubleshooting
<AccordionGroup> <AccordionGroup>
+13 -8
View File
@@ -229,21 +229,26 @@ export async function handleDiscordMessagingAction(
throw new Error("Discord message sends are disabled."); throw new Error("Discord message sends are disabled.");
} }
const to = readStringParam(params, "to", { required: true }); const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "mediaUrl");
const replyTo = readStringParam(params, "replyTo");
const asVoice = params.asVoice === true; const asVoice = params.asVoice === true;
const silent = params.silent === true; const silent = params.silent === true;
const content = readStringParam(params, "content", {
required: !asVoice,
allowEmpty: true,
});
const mediaUrl =
readStringParam(params, "mediaUrl", { trim: false }) ??
readStringParam(params, "path", { trim: false }) ??
readStringParam(params, "filePath", { trim: false });
const replyTo = readStringParam(params, "replyTo");
const embeds = const embeds =
Array.isArray(params.embeds) && params.embeds.length > 0 ? params.embeds : undefined; Array.isArray(params.embeds) && params.embeds.length > 0 ? params.embeds : undefined;
// Handle voice message sending // Handle voice message sending
if (asVoice) { if (asVoice) {
if (!mediaUrl) { if (!mediaUrl) {
throw new Error("Voice messages require a media file path (mediaUrl)."); throw new Error(
"Voice messages require a local media file path (mediaUrl, path, or filePath).",
);
} }
if (content && content.trim()) { if (content && content.trim()) {
throw new Error( throw new Error(
@@ -263,7 +268,7 @@ export async function handleDiscordMessagingAction(
return jsonResult({ ok: true, result, voiceMessage: true }); return jsonResult({ ok: true, result, voiceMessage: true });
} }
const result = await sendMessageDiscord(to, content, { const result = await sendMessageDiscord(to, content ?? "", {
...(accountId ? { accountId } : {}), ...(accountId ? { accountId } : {}),
mediaUrl, mediaUrl,
replyTo, replyTo,
@@ -32,6 +32,7 @@ const removeOwnReactionsDiscord = vi.fn(async () => ({ removed: ["👍"] }));
const removeReactionDiscord = vi.fn(async () => ({})); const removeReactionDiscord = vi.fn(async () => ({}));
const searchMessagesDiscord = vi.fn(async () => ({})); const searchMessagesDiscord = vi.fn(async () => ({}));
const sendMessageDiscord = vi.fn(async () => ({})); const sendMessageDiscord = vi.fn(async () => ({}));
const sendVoiceMessageDiscord = vi.fn(async () => ({}));
const sendPollDiscord = vi.fn(async () => ({})); const sendPollDiscord = vi.fn(async () => ({}));
const sendStickerDiscord = vi.fn(async () => ({})); const sendStickerDiscord = vi.fn(async () => ({}));
const setChannelPermissionDiscord = vi.fn(async () => ({ ok: true })); const setChannelPermissionDiscord = vi.fn(async () => ({ ok: true }));
@@ -64,6 +65,7 @@ vi.mock("../../discord/send.js", () => ({
removeReactionDiscord: (...args: unknown[]) => removeReactionDiscord(...args), removeReactionDiscord: (...args: unknown[]) => removeReactionDiscord(...args),
searchMessagesDiscord: (...args: unknown[]) => searchMessagesDiscord(...args), searchMessagesDiscord: (...args: unknown[]) => searchMessagesDiscord(...args),
sendMessageDiscord: (...args: unknown[]) => sendMessageDiscord(...args), sendMessageDiscord: (...args: unknown[]) => sendMessageDiscord(...args),
sendVoiceMessageDiscord: (...args: unknown[]) => sendVoiceMessageDiscord(...args),
sendPollDiscord: (...args: unknown[]) => sendPollDiscord(...args), sendPollDiscord: (...args: unknown[]) => sendPollDiscord(...args),
sendStickerDiscord: (...args: unknown[]) => sendStickerDiscord(...args), sendStickerDiscord: (...args: unknown[]) => sendStickerDiscord(...args),
setChannelPermissionDiscord: (...args: unknown[]) => setChannelPermissionDiscord(...args), setChannelPermissionDiscord: (...args: unknown[]) => setChannelPermissionDiscord(...args),
@@ -235,6 +237,43 @@ describe("handleDiscordMessagingAction", () => {
); );
}); });
it("sends voice messages from a local file path", async () => {
sendVoiceMessageDiscord.mockClear();
sendMessageDiscord.mockClear();
await handleDiscordMessagingAction(
"sendMessage",
{
to: "channel:123",
path: "/tmp/voice.mp3",
asVoice: true,
silent: true,
},
enableAllActions,
);
expect(sendVoiceMessageDiscord).toHaveBeenCalledWith("channel:123", "/tmp/voice.mp3", {
replyTo: undefined,
silent: true,
});
expect(sendMessageDiscord).not.toHaveBeenCalled();
});
it("rejects voice messages that include content", async () => {
await expect(
handleDiscordMessagingAction(
"sendMessage",
{
to: "channel:123",
mediaUrl: "/tmp/voice.mp3",
asVoice: true,
content: "hello",
},
enableAllActions,
),
).rejects.toThrow(/Voice messages cannot include text content/);
});
it("forwards optional thread content", async () => { it("forwards optional thread content", async () => {
createThreadDiscord.mockClear(); createThreadDiscord.mockClear();
await handleDiscordMessagingAction( await handleDiscordMessagingAction(
+5 -1
View File
@@ -23,7 +23,11 @@ export function registerMessageSendCommand(message: Command, helpers: MessageCli
.option("--reply-to <id>", "Reply-to message id") .option("--reply-to <id>", "Reply-to message id")
.option("--thread-id <id>", "Thread id (Telegram forum thread)") .option("--thread-id <id>", "Thread id (Telegram forum thread)")
.option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false) .option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false)
.option("--silent", "Send message silently without notification (Telegram only)", false), .option(
"--silent",
"Send message silently without notification (Telegram + Discord)",
false,
),
) )
.action(async (opts) => { .action(async (opts) => {
await helpers.runMessageAction("send", opts); await helpers.runMessageAction("send", opts);
+20 -30
View File
@@ -50,7 +50,9 @@ export async function getAudioDuration(filePath: string): Promise<number> {
} }
return Math.round(duration * 100) / 100; // Round to 2 decimal places return Math.round(duration * 100) / 100; // Round to 2 decimal places
} catch (err) { } catch (err) {
throw new Error(`Failed to get audio duration: ${err instanceof Error ? err.message : err}`); throw new Error(`Failed to get audio duration: ${err instanceof Error ? err.message : err}`, {
cause: err,
});
} }
} }
@@ -104,7 +106,7 @@ async function generateWaveformFromPcm(filePath: string): Promise<string> {
let sum = 0; let sum = 0;
let count = 0; let count = 0;
for (let j = 0; j < step && i * step + j < samples.length; j++) { for (let j = 0; j < step && i * step + j < samples.length; j++) {
sum += Math.abs(samples[i * step + j]!); sum += Math.abs(samples[i * step + j]);
count++; count++;
} }
const avg = count > 0 ? sum / count : 0; const avg = count > 0 ? sum / count : 0;
@@ -225,39 +227,27 @@ export async function sendDiscordVoiceMessage(
metadata: VoiceMessageMetadata, metadata: VoiceMessageMetadata,
replyTo: string | undefined, replyTo: string | undefined,
request: RetryRunner, request: RetryRunner,
token: string,
silent?: boolean, silent?: boolean,
): Promise<{ id: string; channel_id: string }> { ): Promise<{ id: string; channel_id: string }> {
const filename = "voice-message.ogg"; const filename = "voice-message.ogg";
const fileSize = audioBuffer.byteLength; const fileSize = audioBuffer.byteLength;
// Step 1: Request upload URL (using fetch directly for proper Content-Type header) // Step 1: Request upload URL from Discord
// Wrapped in retry runner for consistency with other Discord API calls const uploadUrlResponse = await request(
const uploadUrlResponse = await request(async () => { () =>
const res = await fetch(`https://discord.com/api/v10/channels/${channelId}/attachments`, { rest.post(`/channels/${channelId}/attachments`, {
method: "POST", body: {
headers: { files: [
"Content-Type": "application/json", {
Authorization: `Bot ${token}`, filename,
}, file_size: fileSize,
body: JSON.stringify({ id: "0",
files: [ },
{ ],
filename, },
file_size: fileSize, }) as Promise<UploadUrlResponse>,
id: "0", "voice-upload-url",
}, );
],
}),
});
if (!res.ok) {
const errorBody = await res.text();
throw new Error(`Failed to get upload URL: ${res.status} ${errorBody}`);
}
return (await res.json()) as UploadUrlResponse;
}, "voice-upload-url");
if (!uploadUrlResponse.attachments?.[0]) { if (!uploadUrlResponse.attachments?.[0]) {
throw new Error("Failed to get upload URL for voice message"); throw new Error("Failed to get upload URL for voice message");