mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 08:11:26 +00:00
feat(discord): add voice message support
Adds support for sending Discord voice messages via the message tool
with asVoice: true parameter.
Voice messages require:
- OGG/Opus format (auto-converted if needed via ffmpeg)
- Waveform data (generated from audio samples)
- Duration in seconds
- Message flag 8192 (IS_VOICE_MESSAGE)
Implementation:
- New voice-message.ts with audio processing utilities
- getAudioDuration() using ffprobe
- generateWaveform() samples audio and creates base64 waveform
- ensureOggOpus() converts audio to required format
- sendDiscordVoiceMessage() handles 3-step Discord upload process
Usage:
message(action='send', channel='discord', target='...',
path='/path/to/audio.mp3', asVoice=true)
Note: Voice messages cannot include text content (Discord limitation)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { RequestClient } from "@buape/carbon";
|
||||
import type { APIChannel } from "discord-api-types/v10";
|
||||
import { ChannelType, Routes } from "discord-api-types/v10";
|
||||
import fs from "node:fs/promises";
|
||||
import type { RetryConfig } from "../infra/retry.js";
|
||||
import type { PollInput } from "../polls.js";
|
||||
import type { DiscordSendResult } from "./send.types.js";
|
||||
@@ -21,6 +22,11 @@ import {
|
||||
sendDiscordMedia,
|
||||
sendDiscordText,
|
||||
} from "./send.shared.js";
|
||||
import {
|
||||
ensureOggOpus,
|
||||
getVoiceMessageMetadata,
|
||||
sendDiscordVoiceMessage,
|
||||
} from "./voice-message.js";
|
||||
|
||||
type DiscordSendOpts = {
|
||||
token?: string;
|
||||
@@ -31,6 +37,7 @@ type DiscordSendOpts = {
|
||||
replyTo?: string;
|
||||
retry?: RetryConfig;
|
||||
embeds?: unknown[];
|
||||
silent?: boolean;
|
||||
};
|
||||
|
||||
/** Discord thread names are capped at 100 characters. */
|
||||
@@ -131,6 +138,7 @@ export async function sendMessageDiscord(
|
||||
accountInfo.config.maxLinesPerMessage,
|
||||
undefined,
|
||||
chunkMode,
|
||||
opts.silent,
|
||||
);
|
||||
for (const chunk of afterMediaChunks) {
|
||||
await sendDiscordText(
|
||||
@@ -142,6 +150,7 @@ export async function sendMessageDiscord(
|
||||
accountInfo.config.maxLinesPerMessage,
|
||||
undefined,
|
||||
chunkMode,
|
||||
opts.silent,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -155,6 +164,7 @@ export async function sendMessageDiscord(
|
||||
accountInfo.config.maxLinesPerMessage,
|
||||
undefined,
|
||||
chunkMode,
|
||||
opts.silent,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -191,6 +201,7 @@ export async function sendMessageDiscord(
|
||||
accountInfo.config.maxLinesPerMessage,
|
||||
opts.embeds,
|
||||
chunkMode,
|
||||
opts.silent,
|
||||
);
|
||||
} else {
|
||||
result = await sendDiscordText(
|
||||
@@ -202,6 +213,7 @@ export async function sendMessageDiscord(
|
||||
accountInfo.config.maxLinesPerMessage,
|
||||
opts.embeds,
|
||||
chunkMode,
|
||||
opts.silent,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -277,3 +289,87 @@ export async function sendPollDiscord(
|
||||
channelId: String(res.channel_id ?? channelId),
|
||||
};
|
||||
}
|
||||
|
||||
type VoiceMessageOpts = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
verbose?: boolean;
|
||||
rest?: RequestClient;
|
||||
replyTo?: string;
|
||||
retry?: RetryConfig;
|
||||
silent?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a voice message to Discord.
|
||||
*
|
||||
* Voice messages are a special Discord feature that displays audio with a waveform
|
||||
* visualization. They require OGG/Opus format and cannot include text content.
|
||||
*
|
||||
* @param to - Recipient (user ID for DM or channel ID)
|
||||
* @param audioPath - Path to local audio file (will be converted to OGG/Opus if needed)
|
||||
* @param opts - Send options
|
||||
*/
|
||||
export async function sendVoiceMessageDiscord(
|
||||
to: string,
|
||||
audioPath: string,
|
||||
opts: VoiceMessageOpts = {},
|
||||
): Promise<DiscordSendResult> {
|
||||
const cfg = loadConfig();
|
||||
const accountInfo = resolveDiscordAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const { token, rest, request } = createDiscordClient(opts, cfg);
|
||||
const recipient = await parseAndResolveRecipient(to, opts.accountId);
|
||||
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||
|
||||
// Convert to OGG/Opus if needed
|
||||
const { path: oggPath, cleanup } = await ensureOggOpus(audioPath);
|
||||
|
||||
try {
|
||||
// Get voice message metadata (duration and waveform)
|
||||
const metadata = await getVoiceMessageMetadata(oggPath);
|
||||
|
||||
// Read the audio file
|
||||
const audioBuffer = await fs.readFile(oggPath);
|
||||
|
||||
// Send the voice message
|
||||
const result = await sendDiscordVoiceMessage(
|
||||
rest,
|
||||
channelId,
|
||||
audioBuffer,
|
||||
metadata,
|
||||
opts.replyTo,
|
||||
request,
|
||||
opts.silent,
|
||||
);
|
||||
|
||||
recordChannelActivity({
|
||||
channel: "discord",
|
||||
accountId: accountInfo.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
return {
|
||||
messageId: result.id ? String(result.id) : "unknown",
|
||||
channelId: String(result.channel_id ?? channelId),
|
||||
};
|
||||
} catch (err) {
|
||||
throw await buildDiscordSendError(err, {
|
||||
channelId,
|
||||
rest,
|
||||
token,
|
||||
hasMedia: true,
|
||||
});
|
||||
} finally {
|
||||
// Clean up temporary OGG file if we created one
|
||||
if (cleanup) {
|
||||
try {
|
||||
await fs.unlink(oggPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user