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

View File

@@ -393,6 +393,22 @@ Default gate behavior:
| moderation | 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
<AccordionGroup>

View File

@@ -229,21 +229,26 @@ export async function handleDiscordMessagingAction(
throw new Error("Discord message sends are disabled.");
}
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 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 =
Array.isArray(params.embeds) && params.embeds.length > 0 ? params.embeds : undefined;
// Handle voice message sending
if (asVoice) {
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()) {
throw new Error(
@@ -263,7 +268,7 @@ export async function handleDiscordMessagingAction(
return jsonResult({ ok: true, result, voiceMessage: true });
}
const result = await sendMessageDiscord(to, content, {
const result = await sendMessageDiscord(to, content ?? "", {
...(accountId ? { accountId } : {}),
mediaUrl,
replyTo,

View File

@@ -32,6 +32,7 @@ const removeOwnReactionsDiscord = vi.fn(async () => ({ removed: ["👍"] }));
const removeReactionDiscord = vi.fn(async () => ({}));
const searchMessagesDiscord = vi.fn(async () => ({}));
const sendMessageDiscord = vi.fn(async () => ({}));
const sendVoiceMessageDiscord = vi.fn(async () => ({}));
const sendPollDiscord = vi.fn(async () => ({}));
const sendStickerDiscord = vi.fn(async () => ({}));
const setChannelPermissionDiscord = vi.fn(async () => ({ ok: true }));
@@ -64,6 +65,7 @@ vi.mock("../../discord/send.js", () => ({
removeReactionDiscord: (...args: unknown[]) => removeReactionDiscord(...args),
searchMessagesDiscord: (...args: unknown[]) => searchMessagesDiscord(...args),
sendMessageDiscord: (...args: unknown[]) => sendMessageDiscord(...args),
sendVoiceMessageDiscord: (...args: unknown[]) => sendVoiceMessageDiscord(...args),
sendPollDiscord: (...args: unknown[]) => sendPollDiscord(...args),
sendStickerDiscord: (...args: unknown[]) => sendStickerDiscord(...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 () => {
createThreadDiscord.mockClear();
await handleDiscordMessagingAction(

View File

@@ -23,7 +23,11 @@ export function registerMessageSendCommand(message: Command, helpers: MessageCli
.option("--reply-to <id>", "Reply-to message id")
.option("--thread-id <id>", "Thread id (Telegram forum thread)")
.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) => {
await helpers.runMessageAction("send", opts);

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
} 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 count = 0;
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++;
}
const avg = count > 0 ? sum / count : 0;
@@ -225,39 +227,27 @@ export async function sendDiscordVoiceMessage(
metadata: VoiceMessageMetadata,
replyTo: string | undefined,
request: RetryRunner,
token: string,
silent?: boolean,
): Promise<{ id: string; channel_id: string }> {
const filename = "voice-message.ogg";
const fileSize = audioBuffer.byteLength;
// Step 1: Request upload URL (using fetch directly for proper Content-Type header)
// Wrapped in retry runner for consistency with other Discord API calls
const uploadUrlResponse = await request(async () => {
const res = await fetch(`https://discord.com/api/v10/channels/${channelId}/attachments`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bot ${token}`,
},
body: JSON.stringify({
files: [
{
filename,
file_size: fileSize,
id: "0",
},
],
}),
});
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");
// Step 1: Request upload URL from Discord
const uploadUrlResponse = await request(
() =>
rest.post(`/channels/${channelId}/attachments`, {
body: {
files: [
{
filename,
file_size: fileSize,
id: "0",
},
],
},
}) as Promise<UploadUrlResponse>,
"voice-upload-url",
);
if (!uploadUrlResponse.attachments?.[0]) {
throw new Error("Failed to get upload URL for voice message");