Skip to content

Commit caf32bf

Browse files
author
Kai Liu
committed
fix(discord): forward DiscordAPIError code through resolveTextChannel wrapper
Addresses Codex P2 review on PR #211. resolveTextChannel() catches each client.channels.fetch() failure and then rethrows a generic Error("Discord channel ... is not text-based or inaccessible") with no `code` attached. That generic error reaches isPermanentChannelError(), which checks for numeric DiscordAPIErrorcode (10003 Unknown Channel / 50001 Missing Access / etc.) and falls through to false — so a Discord cron job whose bot has been removed from the target channel stays enabled and keeps firing every tick, exactly the noise pattern this PR exists to fix. Capture every Discord API code from each fetch attempt, pick a permanent one (10003 / 50001 / 50013 / 50007) when available, and attach it to the thrown wrapper as `code` (with the full list on `discordErrorCodes` for diagnostics). The wrapper's message is unchanged so callers that match on the string still work. Add a permanent-error.test.ts case asserting the wrapper Error with a forwarded code is classified as permanent. bun test: 419 pass, 1 skip, 0 fail.
1 parent 46fe172 commit caf32bf

2 files changed

Lines changed: 42 additions & 1 deletion

File tree

packages/ims/discord/client.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,20 @@ async function maybeHandleLauncherCommand(params: {
116116
}
117117
async function resolveTextChannel(channelId: string, processorId?: string) {
118118
const attempts: string[] = [];
119+
// Collect Discord API error codes across every fetch attempt so the
120+
// caller can detect permanent-access failures (Unknown Channel / Missing
121+
// Access / etc.) even though we re-throw a generic wrapper Error below.
122+
// discord.js surfaces these on `err.code` as numbers; we forward them on
123+
// the wrapper as `discordErrorCodes` and also expose the first as `code`
124+
// so `isPermanentChannelError` can pick it up via the `code` shape.
125+
const discordErrorCodes: number[] = [];
126+
const captureCode = (error: unknown) => {
127+
if (typeof error === "object" && error !== null) {
128+
const code = (error as { code?: unknown }).code;
129+
if (typeof code === "number") discordErrorCodes.push(code);
130+
}
131+
};
132+
119133
if (processorId) {
120134
const pinnedClient = discordClientByProcessorId.get(processorId);
121135
if (pinnedClient) {
@@ -126,6 +140,7 @@ async function resolveTextChannel(channelId: string, processorId?: string) {
126140
}
127141
attempts.push(`bot=${pinnedClient.user?.id || "unknown"}: channel_not_text_or_missing`);
128142
} catch (error) {
143+
captureCode(error);
129144
const errorMessage = error instanceof Error ? error.message : String(error);
130145
attempts.push(`bot=${pinnedClient.user?.id || "unknown"}: ${errorMessage}`);
131146
}
@@ -141,6 +156,7 @@ async function resolveTextChannel(channelId: string, processorId?: string) {
141156
}
142157
attempts.push(`bot=${client.user?.id || "unknown"}: channel_not_text_or_missing`);
143158
} catch (error) {
159+
captureCode(error);
144160
const errorMessage = error instanceof Error ? error.message : String(error);
145161
attempts.push(`bot=${client.user?.id || "unknown"}: ${errorMessage}`);
146162
}
@@ -154,7 +170,20 @@ async function resolveTextChannel(channelId: string, processorId?: string) {
154170
});
155171
}
156172

157-
throw new Error(`Discord channel ${channelId} is not text-based or inaccessible`);
173+
const wrapper = new Error(`Discord channel ${channelId} is not text-based or inaccessible`);
174+
if (discordErrorCodes.length > 0) {
175+
// Prefer the most informative code: prioritise "permanent" classes
176+
// (10003 Unknown Channel, 50001 Missing Access, 50013 Missing Perms,
177+
// 50007 Cannot DM) so isPermanentChannelError can disable the cron row
178+
// even if some bots failed transiently.
179+
const PERMANENT_PRIORITY = new Set([10003, 50001, 50013, 50007]);
180+
const permanent = discordErrorCodes.find((c) => PERMANENT_PRIORITY.has(c));
181+
(wrapper as Error & { code?: number; discordErrorCodes?: number[] }).code =
182+
permanent ?? discordErrorCodes[0];
183+
(wrapper as Error & { code?: number; discordErrorCodes?: number[] }).discordErrorCodes =
184+
[...discordErrorCodes];
185+
}
186+
throw wrapper;
158187
}
159188

160189
async function buildDiscordContext(

packages/shared/delivery/permanent-error.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ describe("isPermanentChannelError", () => {
3939
).toBe(true);
4040
});
4141

42+
test("matches the Discord resolveTextChannel wrapper when it carries a DiscordAPIError code", () => {
43+
// resolveTextChannel() in packages/ims/discord/client.ts wraps the
44+
// underlying DiscordAPIError in a generic Error("... is not text-based
45+
// or inaccessible"). The wrapper forwards the original numeric `code`
46+
// so this helper can still classify it as permanent.
47+
const wrapper = Object.assign(
48+
new Error("Discord channel 123 is not text-based or inaccessible"),
49+
{ code: 10003 },
50+
);
51+
expect(isPermanentChannelError(wrapper)).toBe(true);
52+
});
53+
4254
test("matches Lark chat_not_found", () => {
4355
expect(isPermanentChannelError(new Error("chat_not_found: chat does not exist"))).toBe(true);
4456
});

0 commit comments

Comments
 (0)