mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-29 13:02:10 +03:00
Discord: refine voice message handling
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,22 +227,16 @@ 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: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bot ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
filename,
|
filename,
|
||||||
@@ -248,16 +244,10 @@ export async function sendDiscordVoiceMessage(
|
|||||||
id: "0",
|
id: "0",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
},
|
||||||
});
|
}) as Promise<UploadUrlResponse>,
|
||||||
|
"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");
|
||||||
|
|||||||
Reference in New Issue
Block a user