Skip to content

Commit e4ff7c1

Browse files
authored
fix: Discord read/search timeout, session-key fallback, and gateway execution mode (#73521)
* fix: Discord read/search timeout, session-key fallback, and gateway execution mode - Add 15s timeout to readMessagesDiscord and searchMessagesDiscord so they fail fast instead of hanging indefinitely (#73431) - Fall back to CommandTargetSessionKey in dispatchReplyFromConfig when SessionKey is empty, so Discord inbound message:received hooks fire reliably (#73431, refs #33038) - Add resolveExecutionMode to Discord channel actions routing read/search through gateway timeout path, matching Telegram's pattern (#73431) * fix: move timeout to fetch layer, drop send.messages wrapper Inject AbortSignal.timeout into the Discord proxy-request-client fetch wrapper so every Discord REST call gets a 15s timeout at the HTTP level. This replaces the Promise.race wrapper in send.messages.ts — cleaner, covers all calls, and actually aborts the TCP connection. * fix: remove unused callerController variable in proxy-request-client test * fix: remove unnecessary mergeAbortSignal helper
1 parent c478aec commit e4ff7c1

7 files changed

Lines changed: 171 additions & 2 deletions

File tree

extensions/discord/src/channel-actions.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,21 @@ describe("discordMessageActions", () => {
113113
expect(discovery?.schema).toBeUndefined();
114114
});
115115

116+
it.each(["read", "search"])("routes %s actions through gateway execution mode", (action) => {
117+
expect(discordMessageActions.resolveExecutionMode?.({ action: action as never })).toBe(
118+
"gateway",
119+
);
120+
});
121+
122+
it.each(["send", "edit", "delete", "react", "pin", "poll"])(
123+
"routes %s actions through local execution mode",
124+
(action) => {
125+
expect(discordMessageActions.resolveExecutionMode?.({ action: action as never })).toBe(
126+
"local",
127+
);
128+
},
129+
);
130+
116131
it("extracts send targets for message and thread reply actions", () => {
117132
expect(
118133
discordMessageActions.extractToolSend?.({

extensions/discord/src/channel-actions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ function describeDiscordMessageTool({
160160
}
161161

162162
export const discordMessageActions: ChannelMessageActionAdapter = {
163+
resolveExecutionMode: ({ action }) =>
164+
action === "read" || action === "search" ? "gateway" : "local",
163165
describeMessageTool: describeDiscordMessageTool,
164166
extractToolSend: ({ args }) => {
165167
const action = normalizeOptionalString(args.action) ?? "";
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { createDiscordRequestClient, DISCORD_REST_TIMEOUT_MS } from "./proxy-request-client.js";
3+
4+
describe("createDiscordRequestClient", () => {
5+
it("injects an abort timeout signal into fetch calls", async () => {
6+
const fetchSpy = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
7+
expect(init?.signal).toBeDefined();
8+
expect(init!.signal!.aborted).toBe(false);
9+
return new Response(JSON.stringify([]), { status: 200 });
10+
});
11+
12+
const client = createDiscordRequestClient("Bot test-token", {
13+
fetch: fetchSpy as never,
14+
queueRequests: false,
15+
});
16+
17+
await client.get("/channels/123/messages");
18+
expect(fetchSpy).toHaveBeenCalledTimes(1);
19+
});
20+
21+
it(
22+
"aborts hanging requests after the timeout",
23+
async () => {
24+
const fetchSpy = vi.fn(
25+
(_input: string | URL | Request, init?: RequestInit) =>
26+
new Promise<Response>((_resolve, reject) => {
27+
init?.signal?.addEventListener("abort", () => {
28+
reject(new DOMException("The operation was aborted.", "AbortError"));
29+
});
30+
}),
31+
);
32+
33+
const client = createDiscordRequestClient("Bot test-token", {
34+
fetch: fetchSpy as never,
35+
queueRequests: false,
36+
});
37+
38+
await expect(client.get("/channels/123/messages")).rejects.toThrow();
39+
},
40+
DISCORD_REST_TIMEOUT_MS + 5_000,
41+
);
42+
43+
it("always injects a timeout signal even without a caller signal", async () => {
44+
let receivedSignal: AbortSignal | undefined;
45+
46+
const fetchSpy = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
47+
receivedSignal = init?.signal ?? undefined;
48+
return new Response(JSON.stringify({}), { status: 200 });
49+
});
50+
51+
const client = createDiscordRequestClient("Bot test-token", {
52+
fetch: fetchSpy as never,
53+
queueRequests: false,
54+
});
55+
56+
await client.get("/channels/123/messages");
57+
58+
expect(receivedSignal).toBeDefined();
59+
expect(receivedSignal!.aborted).toBe(false);
60+
});
61+
62+
it("exports a reasonable timeout constant", () => {
63+
expect(DISCORD_REST_TIMEOUT_MS).toBeGreaterThanOrEqual(5_000);
64+
expect(DISCORD_REST_TIMEOUT_MS).toBeLessThanOrEqual(30_000);
65+
});
66+
});

extensions/discord/src/proxy-request-client.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { FormData as UndiciFormData } from "undici";
33

44
export type ProxyRequestClientOptions = RequestClientOptions;
55

6+
export const DISCORD_REST_TIMEOUT_MS = 15_000;
7+
68
function toUndiciFormData(body: FormData): UndiciFormData {
79
const converted = new UndiciFormData();
810
for (const [key, value] of body.entries()) {
@@ -22,15 +24,17 @@ function toUndiciFormData(body: FormData): UndiciFormData {
2224

2325
function wrapDiscordFetch(fetchImpl: NonNullable<RequestClientOptions["fetch"]>) {
2426
return (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
27+
const signal = AbortSignal.timeout(DISCORD_REST_TIMEOUT_MS);
2528
if (init?.body instanceof FormData) {
2629
// Carbon builds global FormData; undici-backed proxy fetch needs undici's
2730
// FormData class to preserve multipart boundaries.
2831
return fetchImpl(input, {
2932
...init,
33+
signal,
3034
body: toUndiciFormData(init.body) as unknown as BodyInit,
3135
});
3236
}
33-
return fetchImpl(input, init);
37+
return fetchImpl(input, { ...init, signal });
3438
};
3539
}
3640

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
const restMock = {
4+
get: vi.fn(),
5+
};
6+
7+
vi.mock("./send.shared.js", () => ({
8+
resolveDiscordRest: () => restMock,
9+
}));
10+
11+
const { readMessagesDiscord, searchMessagesDiscord } = await import("./send.messages.js");
12+
13+
describe("readMessagesDiscord", () => {
14+
it("returns messages from the REST client", async () => {
15+
const messages = [{ id: "1", content: "hello" }];
16+
restMock.get.mockResolvedValueOnce(messages);
17+
18+
const result = await readMessagesDiscord("C1", { limit: 5 }, { cfg: {} as never });
19+
20+
expect(result).toEqual(messages);
21+
expect(restMock.get).toHaveBeenCalledWith(expect.stringContaining("C1"), { limit: 5 });
22+
});
23+
24+
it("propagates REST errors", async () => {
25+
restMock.get.mockRejectedValueOnce(new Error("Discord API error"));
26+
27+
await expect(readMessagesDiscord("C1", {}, { cfg: {} as never })).rejects.toThrow(
28+
"Discord API error",
29+
);
30+
});
31+
});
32+
33+
describe("searchMessagesDiscord", () => {
34+
it("returns search results from the REST client", async () => {
35+
const results = { messages: [[{ id: "1" }]], total_results: 1 };
36+
restMock.get.mockResolvedValueOnce(results);
37+
38+
const result = await searchMessagesDiscord(
39+
{ guildId: "G1", content: "test", limit: 1 },
40+
{ cfg: {} as never },
41+
);
42+
43+
expect(result).toEqual(results);
44+
});
45+
46+
it("propagates REST errors", async () => {
47+
restMock.get.mockRejectedValueOnce(new Error("Discord API error"));
48+
49+
await expect(
50+
searchMessagesDiscord({ guildId: "G1", content: "test" }, { cfg: {} as never }),
51+
).rejects.toThrow("Discord API error");
52+
});
53+
});

src/auto-reply/reply/dispatch-from-config.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2824,6 +2824,34 @@ describe("dispatchReplyFromConfig", () => {
28242824
expect(internalHookMocks.triggerInternalHook).not.toHaveBeenCalled();
28252825
});
28262826

2827+
it("falls back to CommandTargetSessionKey for internal hook when SessionKey is empty", async () => {
2828+
setNoAbort();
2829+
const cfg = emptyConfig;
2830+
const dispatcher = createDispatcher();
2831+
const ctx = buildTestCtx({
2832+
Provider: "discord",
2833+
Surface: "discord",
2834+
CommandBody: "hello",
2835+
MessageSid: "msg-99",
2836+
});
2837+
(ctx as MsgContext).SessionKey = undefined;
2838+
(ctx as MsgContext).CommandTargetSessionKey = "agent:main:discord:guild:123";
2839+
2840+
const replyResolver = async () => ({ text: "reply" }) satisfies ReplyPayload;
2841+
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
2842+
2843+
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledWith(
2844+
"message",
2845+
"received",
2846+
"agent:main:discord:guild:123",
2847+
expect.objectContaining({
2848+
content: "hello",
2849+
messageId: "msg-99",
2850+
}),
2851+
);
2852+
expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1);
2853+
});
2854+
28272855
it("emits diagnostics when enabled", async () => {
28282856
setNoAbort();
28292857
const cfg = { diagnostics: { enabled: true } } as OpenClawConfig;

src/auto-reply/reply/dispatch-from-config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,8 @@ export async function dispatchReplyFromConfig(
292292
const channel = normalizeLowercaseStringOrEmpty(ctx.Surface ?? ctx.Provider ?? "unknown");
293293
const chatId = ctx.To ?? ctx.From;
294294
const messageId = ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast;
295-
const sessionKey = ctx.SessionKey;
295+
const sessionKey =
296+
normalizeOptionalString(ctx.SessionKey) ?? normalizeOptionalString(ctx.CommandTargetSessionKey);
296297
const startTime = diagnosticsEnabled ? Date.now() : 0;
297298
const canTrackSession = diagnosticsEnabled && Boolean(sessionKey);
298299

0 commit comments

Comments
 (0)