diff --git a/CHANGELOG.md b/CHANGELOG.md index 049320ab717..e097a5aee8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Docs/tool-loop detection config keys: align `docs/tools/loop-detection.md` examples and field names with the current `tools.loopDetection` schema to prevent copy-paste validation failures from outdated keys. (#33182) Thanks @Mylszd. - Discord/presence defaults: send an online presence update on ready when no custom presence is configured so bots no longer appear offline by default. Thanks @thewilloftheshadow. - Discord/typing cleanup: stop typing indicators after silent/NO_REPLY runs by marking the run complete before dispatch idle cleanup. Thanks @thewilloftheshadow. +- Discord/voice messages: request upload slots with JSON fetch calls so voice message uploads no longer fail with content-type errors. Thanks @thewilloftheshadow. - Telegram/DM draft finalization reliability: require verified final-text draft emission before treating preview finalization as delivered, and fall back to normal payload send when final draft delivery is not confirmed (preventing missing final responses and preserving media/button delivery). (#32118) Thanks @OpenCils. - Discord/audit wildcard warnings: ignore "\*" wildcard keys when counting unresolved guild channels so doctor/status no longer warns on allow-all configs. (#33125) Thanks @thewilloftheshadow. - Discord/channel resolution: default bare numeric recipients to channels, harden allowlist numeric ID handling with safe fallbacks, and avoid inbound WS heartbeat stalls. (#33142) Thanks @thewilloftheshadow. diff --git a/scripts/check-no-raw-channel-fetch.mjs b/scripts/check-no-raw-channel-fetch.mjs index 566034c6ca9..ecd8a2f64f8 100644 --- a/scripts/check-no-raw-channel-fetch.mjs +++ b/scripts/check-no-raw-channel-fetch.mjs @@ -56,7 +56,8 @@ const allowedRawFetchCallsites = new Set([ "extensions/voice-call/src/providers/twilio/api.ts:23", "src/channels/telegram/api.ts:8", "src/discord/send.outbound.ts:347", - "src/discord/voice-message.ts:267", + "src/discord/voice-message.ts:264", + "src/discord/voice-message.ts:308", "src/slack/monitor/media.ts:64", "src/slack/monitor/media.ts:68", "src/slack/monitor/media.ts:82", diff --git a/src/discord/send.outbound.ts b/src/discord/send.outbound.ts index ce13321ba00..da4ef1ff0c4 100644 --- a/src/discord/send.outbound.ts +++ b/src/discord/send.outbound.ts @@ -524,6 +524,7 @@ export async function sendVoiceMessageDiscord( opts.replyTo, request, opts.silent, + token, ); recordChannelActivity({ diff --git a/src/discord/voice-message.ts b/src/discord/voice-message.ts index 3891babfff3..fcda7113793 100644 --- a/src/discord/voice-message.ts +++ b/src/discord/voice-message.ts @@ -13,7 +13,7 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; -import type { RequestClient } from "@buape/carbon"; +import { RateLimitError, type RequestClient } from "@buape/carbon"; import type { RetryRunner } from "../infra/retry-policy.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { parseFfprobeCodecAndSampleRate, runFfmpeg, runFfprobe } from "../media/ffmpeg-exec.js"; @@ -245,26 +245,57 @@ export async function sendDiscordVoiceMessage( replyTo: string | undefined, request: RetryRunner, silent?: boolean, + token?: string, ): Promise<{ id: string; channel_id: string }> { const filename = "voice-message.ogg"; const fileSize = audioBuffer.byteLength; // 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, - "voice-upload-url", - ); + // Must use fetch() directly instead of rest.post() because @buape/carbon's + // RequestClient auto-converts requests to multipart/form-data when the body + // contains a "files" key. Discord's /attachments endpoint expects JSON, so + // the auto-conversion causes HTTP 400 "Expected Content-Type application/json". + const botToken = token; + if (!botToken) { + throw new Error("Discord bot token is required for voice message upload"); + } + const uploadUrlResponse = await request(async () => { + const url = `${rest.options?.baseUrl ?? "https://discord.com/api"}/channels/${channelId}/attachments`; + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bot ${botToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + files: [{ filename, file_size: fileSize, id: "0" }], + }), + }); + if (!res.ok) { + if (res.status === 429) { + const retryData = (await res.json().catch(() => ({}))) as { + message?: string; + retry_after?: number; + global?: boolean; + }; + throw new RateLimitError(res, { + message: retryData.message ?? "You are being rate limited.", + retry_after: retryData.retry_after ?? 1, + global: retryData.global ?? false, + }); + } + const errorBody = (await res.json().catch(() => null)) as { + code?: number; + message?: string; + } | null; + const err = new Error(`Upload URL request failed: ${res.status} ${errorBody?.message ?? ""}`); + if (errorBody?.code !== undefined) { + (err as Error & { code: number }).code = errorBody.code; + } + throw err; + } + return (await res.json()) as UploadUrlResponse; + }, "voice-upload-url"); if (!uploadUrlResponse.attachments?.[0]) { throw new Error("Failed to get upload URL for voice message");