mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-11 13:06:08 +00:00
[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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
84
extensions/discord/src/send.guild.test.ts
Normal file
84
extensions/discord/src/send.guild.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user