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:
nyanjou
2026-02-02 17:00:19 +01:00
committed by Shadow
parent aec3221391
commit a09e4fac3f
5 changed files with 444 additions and 1 deletions

View File

@@ -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
}
}
}
}