diff --git a/extensions/discord/src/internal/rest-errors.ts b/extensions/discord/src/internal/rest-errors.ts index 07a64a6cdc71..ab6b19ffefc7 100644 --- a/extensions/discord/src/internal/rest-errors.ts +++ b/extensions/discord/src/internal/rest-errors.ts @@ -1,7 +1,10 @@ // Discord plugin module implements rest errors behavior. +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime"; import { parseDiscordRetryAfterBodySeconds, parseRetryAfterHeaderSeconds } from "../retry-after.js"; +const DISCORD_UNKNOWN_VOICE_STATE = 10065; + export function readDiscordCode(body: unknown): number | undefined { const value = body && typeof body === "object" && "code" in body @@ -18,6 +21,17 @@ export function readDiscordMessage(body: unknown, fallback: string): string { return typeof value === "string" && value.trim() ? value : fallback; } +export function isUnknownDiscordVoiceStateError(err: unknown): boolean { + const discordCode = + err && typeof err === "object" && "discordCode" in err + ? parseStrictNonNegativeInteger(err.discordCode) + : undefined; + return ( + discordCode === DISCORD_UNKNOWN_VOICE_STATE || + /unknown voice state/i.test(formatErrorMessage(err)) + ); +} + export function readRetryAfter(body: unknown, response: Response, fallbackSeconds = 0): number { const bodyValue = body && typeof body === "object" && "retry_after" in body diff --git a/extensions/discord/src/internal/rest.ts b/extensions/discord/src/internal/rest.ts index dc3fa5db52cb..3d42c4c44b1a 100644 --- a/extensions/discord/src/internal/rest.ts +++ b/extensions/discord/src/internal/rest.ts @@ -24,7 +24,7 @@ import { } from "./rest-scheduler.js"; import { isDiscordRateLimitBody } from "./schemas.js"; -export { DiscordError, RateLimitError } from "./rest-errors.js"; +export { DiscordError, isUnknownDiscordVoiceStateError, RateLimitError } from "./rest-errors.js"; export type RuntimeProfile = "serverless" | "persistent"; export type RequestPriority = RestRequestPriority; diff --git a/extensions/discord/src/send.guild.test.ts b/extensions/discord/src/send.guild.test.ts new file mode 100644 index 000000000000..fbb68056828d --- /dev/null +++ b/extensions/discord/src/send.guild.test.ts @@ -0,0 +1,84 @@ +import { Routes } from "discord-api-types/v10"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const restMock = { + get: vi.fn(), +}; + +vi.mock("./send.shared.js", () => ({ + resolveDiscordRest: () => restMock, +})); + +vi.mock("openclaw/plugin-sdk/web-media", () => ({ + loadWebMediaRaw: vi.fn(), +})); + +const { fetchVoiceStatusDiscord } = await import("./send.guild.js"); + +describe("fetchVoiceStatusDiscord", () => { + beforeEach(() => { + restMock.get.mockReset(); + }); + + it("returns active voice states from the REST client", async () => { + const voiceState = { + guild_id: "g1", + user_id: "u1", + channel_id: "c1", + session_id: "s1", + deaf: false, + mute: false, + self_deaf: false, + self_mute: false, + suppress: false, + }; + restMock.get.mockResolvedValueOnce(voiceState); + + const result = await fetchVoiceStatusDiscord("g1", "u1", { cfg: {} as never }); + + expect(result).toEqual(voiceState); + expect(restMock.get).toHaveBeenCalledWith(Routes.guildVoiceState("g1", "u1")); + }); + + it("returns an absent status when Discord reports an unknown voice state", async () => { + restMock.get.mockRejectedValueOnce( + Object.assign(new Error("Not Found"), { status: 404, discordCode: 10065 }), + ); + + const result = await fetchVoiceStatusDiscord("g1", "u1", { cfg: {} as never }); + + expect(result).toEqual({ + guild_id: "g1", + user_id: "u1", + channel_id: null, + connected: false, + absent: true, + reason: "unknown_voice_state", + }); + }); + + it("recognizes legacy unknown voice state error messages", async () => { + restMock.get.mockRejectedValueOnce(new Error("DiscordError: Unknown Voice State")); + + const result = await fetchVoiceStatusDiscord("g1", "u1", { cfg: {} as never }); + + expect(result).toMatchObject({ + guild_id: "g1", + user_id: "u1", + channel_id: null, + connected: false, + absent: true, + reason: "unknown_voice_state", + }); + }); + + it.each([ + Object.assign(new Error("Unknown Guild"), { status: 404, discordCode: 10004 }), + Object.assign(new Error("Not Found"), { status: 404 }), + new Error("Discord API error"), + ])("propagates non-voice-state REST failures", async (error) => { + restMock.get.mockRejectedValueOnce(error); + + await expect(fetchVoiceStatusDiscord("g1", "u1", { cfg: {} as never })).rejects.toBe(error); + }); +}); diff --git a/extensions/discord/src/send.guild.ts b/extensions/discord/src/send.guild.ts index 0517b0a5d31a..27f4bc157bef 100644 --- a/extensions/discord/src/send.guild.ts +++ b/extensions/discord/src/send.guild.ts @@ -21,6 +21,7 @@ import { getGuild, getGuildMember, getGuildVoiceState, + isUnknownDiscordVoiceStateError, listGuildChannels, listGuildRoles, listGuildScheduledEvents, @@ -38,6 +39,14 @@ import type { } from "./send.types.js"; import { DISCORD_MAX_EVENT_COVER_BYTES } from "./send.types.js"; +export type DiscordAbsentVoiceState = Pick & { + connected: false; + absent: true; + reason: "unknown_voice_state"; +}; + +export type DiscordVoiceStatus = APIVoiceState | DiscordAbsentVoiceState; + export async function fetchMemberInfoDiscord( guildId: string, userId: string, @@ -95,9 +104,23 @@ export async function fetchVoiceStatusDiscord( guildId: string, userId: string, opts: DiscordReactOpts, -): Promise { +): Promise { const rest = resolveDiscordRest(opts); - return await getGuildVoiceState(rest, guildId, userId); + try { + return await getGuildVoiceState(rest, guildId, userId); + } catch (err) { + if (!isUnknownDiscordVoiceStateError(err)) { + throw err; + } + return { + guild_id: guildId, + user_id: userId, + channel_id: null, + connected: false, + absent: true, + reason: "unknown_voice_state", + }; + } } export async function listScheduledEventsDiscord( diff --git a/extensions/discord/src/voice/manager.ts b/extensions/discord/src/voice/manager.ts index 76d3355e7c8c..a35d8c77d76c 100644 --- a/extensions/discord/src/voice/manager.ts +++ b/extensions/discord/src/voice/manager.ts @@ -10,6 +10,7 @@ import { type APIVoiceState, type Client, getGuildVoiceState, + isUnknownDiscordVoiceStateError, ReadyListener, ResumedListener, VoiceStateUpdateListener, @@ -203,14 +204,6 @@ function isFatalAutoJoinFailure(message: string): boolean { ); } -function isUnknownDiscordVoiceStateError(err: unknown): boolean { - const status = - err && typeof err === "object" && "status" in err && typeof err.status === "number" - ? err.status - : undefined; - return status === 404 || /unknown voice state/i.test(formatErrorMessage(err)); -} - function startAutoJoin(manager: Pick) { void manager .autoJoin()