mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
210aca6de3
Re-lands the reverted #121507 on the current custody contracts. The reply dispatcher now owns direct-send pending-final custody: claim before provider I/O, terminal settlement for delivered/suppressed/failed outcomes, proven no-send stays replayable, ambiguous evidence fails closed — so Gateway restarts can no longer duplicate an already-accepted final reply. Proof: ClawSweeper local review clean, exact-head ci-gate green, live Telegram E2E (one turn, one final, no duplicates). Co-authored-by: Ayaan Zaidi <hi@obviy.us>
161 lines
5.1 KiB
TypeScript
161 lines
5.1 KiB
TypeScript
// Discord plugin module implements send.voice behavior.
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
|
|
import {
|
|
buildOutboundMediaLoadOptions,
|
|
extensionForMime,
|
|
maxBytesForKind,
|
|
unlinkIfExists,
|
|
} from "openclaw/plugin-sdk/media-runtime";
|
|
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
|
|
import { withTempWorkspace, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
|
import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media";
|
|
import { resolveDiscordAccount } from "./accounts.js";
|
|
import type { RequestClient } from "./internal/discord.js";
|
|
import { parseAndResolveChannelRecipient } from "./recipient-resolution.js";
|
|
import type { DiscordReplyReference } from "./reply-reference.js";
|
|
import type { sendMessageDiscord } from "./send.outbound.js";
|
|
import { createDiscordSendResult } from "./send.receipt.js";
|
|
import { buildDiscordSendError, createDiscordClient, resolveChannelId } from "./send.shared.js";
|
|
import type { DiscordSendResult } from "./send.types.js";
|
|
import {
|
|
ensureOggOpus,
|
|
getVoiceMessageMetadata,
|
|
sendDiscordVoiceMessage,
|
|
} from "./voice-message.js";
|
|
|
|
type VoiceMessageOpts = Pick<
|
|
Parameters<typeof sendMessageDiscord>[2],
|
|
| "cfg"
|
|
| "token"
|
|
| "accountId"
|
|
| "verbose"
|
|
| "rest"
|
|
| "reply"
|
|
| "retry"
|
|
| "silent"
|
|
| "mediaAccess"
|
|
| "mediaLocalRoots"
|
|
| "mediaReadFile"
|
|
| "onPlatformSendDispatch"
|
|
>;
|
|
|
|
function toDiscordSendResult(
|
|
result: { id?: string | null; channel_id?: string | null },
|
|
fallbackChannelId: string,
|
|
reply?: DiscordReplyReference,
|
|
): DiscordSendResult {
|
|
return createDiscordSendResult({
|
|
result,
|
|
fallbackChannelId,
|
|
kind: "voice",
|
|
reply,
|
|
});
|
|
}
|
|
|
|
async function withMaterializedVoiceMessageInput<T>(
|
|
mediaUrl: string,
|
|
opts: VoiceMessageOpts,
|
|
run: (filePath: string) => Promise<T>,
|
|
): Promise<T> {
|
|
// Security: reuse the standard media loader so we apply SSRF guards + allowed-local-root checks.
|
|
// Then write to a private temp file so ffmpeg/ffprobe never sees the original URL/path string.
|
|
const media = await loadWebMediaRaw(
|
|
mediaUrl,
|
|
buildOutboundMediaLoadOptions({
|
|
maxBytes: maxBytesForKind("audio"),
|
|
mediaAccess: opts.mediaAccess,
|
|
mediaLocalRoots: opts.mediaLocalRoots,
|
|
mediaReadFile: opts.mediaReadFile,
|
|
}),
|
|
);
|
|
const extFromName = media.fileName ? path.extname(media.fileName) : "";
|
|
const extFromMime = media.contentType ? extensionForMime(media.contentType) : "";
|
|
const ext = extFromName || extFromMime || ".bin";
|
|
return await withTempWorkspace(
|
|
{
|
|
rootDir: resolvePreferredOpenClawTmpDir(),
|
|
prefix: "voice-src-",
|
|
},
|
|
async (workspace) => await run(await workspace.write(`input${ext}`, media.buffer)),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 = requireRuntimeConfig(opts.cfg, "Discord voice send");
|
|
return await withMaterializedVoiceMessageInput(audioPath, opts, async (localInputPath) => {
|
|
let oggPath: string | null = null;
|
|
let oggCleanup = false;
|
|
let token: string | undefined;
|
|
let rest: RequestClient | undefined;
|
|
let channelId: string | undefined;
|
|
|
|
try {
|
|
const accountInfo = resolveDiscordAccount({
|
|
cfg,
|
|
accountId: opts.accountId,
|
|
});
|
|
const client = createDiscordClient({ ...opts, cfg });
|
|
token = client.token;
|
|
rest = client.rest;
|
|
const request = client.request;
|
|
const recipient = await parseAndResolveChannelRecipient(to, cfg, opts.accountId);
|
|
channelId = (await resolveChannelId(rest, recipient, request)).channelId;
|
|
|
|
const ogg = await ensureOggOpus(localInputPath);
|
|
oggPath = ogg.path;
|
|
oggCleanup = ogg.cleanup;
|
|
|
|
const metadata = await getVoiceMessageMetadata(oggPath);
|
|
const audioBuffer = await fs.readFile(oggPath);
|
|
await opts.onPlatformSendDispatch?.();
|
|
const result = await sendDiscordVoiceMessage(
|
|
rest,
|
|
channelId,
|
|
audioBuffer,
|
|
metadata,
|
|
opts.reply?.messageId,
|
|
request,
|
|
opts.silent,
|
|
token,
|
|
);
|
|
|
|
recordChannelActivity({
|
|
channel: "discord",
|
|
accountId: accountInfo.accountId,
|
|
direction: "outbound",
|
|
});
|
|
|
|
return toDiscordSendResult(result, channelId, opts.reply);
|
|
} catch (err) {
|
|
if (channelId && rest && token) {
|
|
throw await buildDiscordSendError(err, {
|
|
channelId,
|
|
cfg,
|
|
rest,
|
|
token,
|
|
hasMedia: true,
|
|
});
|
|
}
|
|
throw err;
|
|
} finally {
|
|
await unlinkIfExists(oggCleanup ? oggPath : null);
|
|
}
|
|
});
|
|
}
|