[codex] fix discord missing voice state handling (#90969)

* fix(discord): treat missing voice state as absent

* fix(discord): narrow absent voice state handling

* chore: defer Discord voice changelog

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
asock
2026-07-05 17:10:46 -04:00
committed by GitHub
parent e4c6ee8697
commit 20163ee182
5 changed files with 125 additions and 11 deletions

View File

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

View File

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

View File

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

View File

@@ -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<APIVoiceState, "guild_id" | "user_id" | "channel_id"> & {
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<APIVoiceState> {
): Promise<DiscordVoiceStatus> {
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(

View File

@@ -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<DiscordVoiceManager, "autoJoin">) {
void manager
.autoJoin()