Skip to content

Commit 97e56cb

Browse files
steipeteopenperfchilu18Yipshlbo728
committed
fix(discord): land proxy/media/reaction/model-picker regressions
Reimplements core Discord fixes from #25277 #25523 #25575 #25588 #25731 with expanded tests. - thread proxy-aware fetch into inbound attachment/sticker downloads - fetch /gateway/bot via proxy dispatcher before ws connect - wire statusReactions emojis/timing overrides into controller - compact model-picker custom_id keys with backward-compatible parsing Co-authored-by: openperf <[email protected]> Co-authored-by: chilu18 <[email protected]> Co-authored-by: Yipsh <[email protected]> Co-authored-by: lbo728 <[email protected]> Co-authored-by: s1korrrr <[email protected]>
1 parent 55cf925 commit 97e56cb

12 files changed

+265
-23
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
2323
- Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko.
2424
- Models/Bedrock auth: normalize additional Bedrock provider aliases (`bedrock`, `aws-bedrock`, `aws_bedrock`, `amazon bedrock`) to canonical `amazon-bedrock`, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13.
2525
- Automation/Subagent/Cron reliability: honor `ANNOUNCE_SKIP` in `sessions_spawn` completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include `cron` in the `coding` tool profile so `/tools/invoke` can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky.
26+
- Discord/Proxy + reactions + model picker: thread channel proxy fetch into inbound media/sticker downloads, use proxy-aware gateway metadata fetch for WSL/corporate proxy setups, wire `messages.statusReactions.{emojis,timing}` into Discord reaction lifecycle control, and compact model-picker `custom_id` keys to stay under Discord's 100-char limit while keeping backward-compatible parsing. (#25232, #25507, #25564, #25695) Thanks @openperf, @chilu18, @Yipsh, @lbo728, and @s1korrrr.
2627
- Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all `block` payloads), fixing missing Discord replies in `channels.discord.streaming=block` mode. (#25839, #25836, #25792) Thanks @pewallin.
2728
- Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall.
2829
- Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not `; ` joins) to avoid POSIX `sh` `do;` syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility.

src/discord/monitor/gateway-plugin.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
2+
import type { APIGatewayBotInfo } from "discord-api-types/v10";
23
import { HttpsProxyAgent } from "https-proxy-agent";
4+
import { ProxyAgent, fetch as undiciFetch } from "undici";
35
import WebSocket from "ws";
46
import type { DiscordAccountConfig } from "../../config/types.js";
57
import { danger } from "../../globals.js";
@@ -42,7 +44,8 @@ export function createDiscordGatewayPlugin(params: {
4244
}
4345

4446
try {
45-
const agent = new HttpsProxyAgent<string>(proxy);
47+
const wsAgent = new HttpsProxyAgent<string>(proxy);
48+
const fetchAgent = new ProxyAgent(proxy);
4649

4750
params.runtime.log?.("discord: gateway proxy enabled");
4851

@@ -51,8 +54,28 @@ export function createDiscordGatewayPlugin(params: {
5154
super(options);
5255
}
5356

54-
createWebSocket(url: string) {
55-
return new WebSocket(url, { agent });
57+
override async registerClient(client: Parameters<GatewayPlugin["registerClient"]>[0]) {
58+
if (!this.gatewayInfo) {
59+
try {
60+
const response = await undiciFetch("https://discord.com/api/v10/gateway/bot", {
61+
headers: {
62+
Authorization: `Bot ${client.options.token}`,
63+
},
64+
dispatcher: fetchAgent,
65+
} as Record<string, unknown>);
66+
this.gatewayInfo = (await response.json()) as APIGatewayBotInfo;
67+
} catch (error) {
68+
throw new Error(
69+
`Failed to get gateway information from Discord: ${error instanceof Error ? error.message : String(error)}`,
70+
{ cause: error },
71+
);
72+
}
73+
}
74+
return super.registerClient(client);
75+
}
76+
77+
override createWebSocket(url: string) {
78+
return new WebSocket(url, { agent: wsAgent });
5679
}
5780
}
5881

src/discord/monitor/message-handler.preflight.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,5 +733,6 @@ export async function preflightDiscordMessage(
733733
canDetectMention,
734734
historyEntry,
735735
threadBindings: params.threadBindings,
736+
discordRestFetch: params.discordRestFetch,
736737
};
737738
}

src/discord/monitor/message-handler.preflight.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export type DiscordMessagePreflightContext = {
8484

8585
historyEntry?: HistoryEntry;
8686
threadBindings: ThreadBindingManager;
87+
discordRestFetch?: typeof fetch;
8788
};
8889

8990
export type DiscordMessagePreflightParams = {
@@ -106,6 +107,7 @@ export type DiscordMessagePreflightParams = {
106107
ackReactionScope: DiscordMessagePreflightContext["ackReactionScope"];
107108
groupPolicy: DiscordMessagePreflightContext["groupPolicy"];
108109
threadBindings: ThreadBindingManager;
110+
discordRestFetch?: typeof fetch;
109111
data: DiscordMessageEvent;
110112
client: Client;
111113
};

src/discord/monitor/message-handler.process.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,35 @@ describe("processDiscordMessage ack reactions", () => {
257257
expect(emojis).toContain(DEFAULT_EMOJIS.stallHard);
258258
expect(emojis).toContain(DEFAULT_EMOJIS.done);
259259
});
260+
261+
it("applies status reaction emoji/timing overrides from config", async () => {
262+
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
263+
await params?.replyOptions?.onReasoningStream?.();
264+
return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } };
265+
});
266+
267+
const ctx = await createBaseContext({
268+
cfg: {
269+
messages: {
270+
ackReaction: "👀",
271+
statusReactions: {
272+
emojis: { queued: "🟦", thinking: "🧪", done: "🏁" },
273+
timing: { debounceMs: 0 },
274+
},
275+
},
276+
session: { store: "/tmp/openclaw-discord-process-test-sessions.json" },
277+
},
278+
});
279+
280+
// oxlint-disable-next-line typescript/no-explicit-any
281+
await processDiscordMessage(ctx as any);
282+
283+
const emojis = (
284+
sendMocks.reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]>
285+
).map((call) => call[2]);
286+
expect(emojis).toContain("🟦");
287+
expect(emojis).toContain("🏁");
288+
});
260289
});
261290

262291
describe("processDiscordMessage session routing", () => {

src/discord/monitor/message-handler.process.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,15 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
101101
threadBindings,
102102
route,
103103
commandAuthorized,
104+
discordRestFetch,
104105
} = ctx;
105106

106-
const mediaList = await resolveMediaList(message, mediaMaxBytes);
107-
const forwardedMediaList = await resolveForwardedMediaList(message, mediaMaxBytes);
107+
const mediaList = await resolveMediaList(message, mediaMaxBytes, discordRestFetch);
108+
const forwardedMediaList = await resolveForwardedMediaList(
109+
message,
110+
mediaMaxBytes,
111+
discordRestFetch,
112+
);
108113
mediaList.push(...forwardedMediaList);
109114
const text = messageText;
110115
if (!text) {
@@ -147,6 +152,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
147152
enabled: statusReactionsEnabled,
148153
adapter: discordAdapter,
149154
initialEmoji: ackReaction,
155+
emojis: cfg.messages?.statusReactions?.emojis,
156+
timing: cfg.messages?.statusReactions?.timing,
150157
onError: (err) => {
151158
logAckFailure({
152159
log: logVerbose,

src/discord/monitor/message-utils.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ describe("resolveForwardedMediaList", () => {
9393
url: attachment.url,
9494
filePathHint: attachment.filename,
9595
maxBytes: 512,
96+
fetchImpl: undefined,
9697
});
9798
expect(saveMediaBuffer).toHaveBeenCalledTimes(1);
9899
expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512);
@@ -105,6 +106,38 @@ describe("resolveForwardedMediaList", () => {
105106
]);
106107
});
107108

109+
it("forwards fetchImpl to forwarded attachment downloads", async () => {
110+
const proxyFetch = vi.fn() as unknown as typeof fetch;
111+
const attachment = {
112+
id: "att-proxy",
113+
url: "https://cdn.discordapp.com/attachments/1/proxy.png",
114+
filename: "proxy.png",
115+
content_type: "image/png",
116+
};
117+
fetchRemoteMedia.mockResolvedValueOnce({
118+
buffer: Buffer.from("image"),
119+
contentType: "image/png",
120+
});
121+
saveMediaBuffer.mockResolvedValueOnce({
122+
path: "/tmp/proxy.png",
123+
contentType: "image/png",
124+
});
125+
126+
await resolveForwardedMediaList(
127+
asMessage({
128+
rawData: {
129+
message_snapshots: [{ message: { attachments: [attachment] } }],
130+
},
131+
}),
132+
512,
133+
proxyFetch,
134+
);
135+
136+
expect(fetchRemoteMedia).toHaveBeenCalledWith(
137+
expect.objectContaining({ fetchImpl: proxyFetch }),
138+
);
139+
});
140+
108141
it("downloads forwarded stickers", async () => {
109142
const sticker = {
110143
id: "sticker-1",
@@ -134,6 +167,7 @@ describe("resolveForwardedMediaList", () => {
134167
url: "https://media.discordapp.net/stickers/sticker-1.png",
135168
filePathHint: "wave.png",
136169
maxBytes: 512,
170+
fetchImpl: undefined,
137171
});
138172
expect(saveMediaBuffer).toHaveBeenCalledTimes(1);
139173
expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512);
@@ -201,6 +235,7 @@ describe("resolveMediaList", () => {
201235
url: "https://media.discordapp.net/stickers/sticker-2.png",
202236
filePathHint: "hello.png",
203237
maxBytes: 512,
238+
fetchImpl: undefined,
204239
});
205240
expect(saveMediaBuffer).toHaveBeenCalledTimes(1);
206241
expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512);
@@ -212,6 +247,35 @@ describe("resolveMediaList", () => {
212247
},
213248
]);
214249
});
250+
251+
it("forwards fetchImpl to sticker downloads", async () => {
252+
const proxyFetch = vi.fn() as unknown as typeof fetch;
253+
const sticker = {
254+
id: "sticker-proxy",
255+
name: "proxy-sticker",
256+
format_type: StickerFormatType.PNG,
257+
};
258+
fetchRemoteMedia.mockResolvedValueOnce({
259+
buffer: Buffer.from("sticker"),
260+
contentType: "image/png",
261+
});
262+
saveMediaBuffer.mockResolvedValueOnce({
263+
path: "/tmp/sticker-proxy.png",
264+
contentType: "image/png",
265+
});
266+
267+
await resolveMediaList(
268+
asMessage({
269+
stickers: [sticker],
270+
}),
271+
512,
272+
proxyFetch,
273+
);
274+
275+
expect(fetchRemoteMedia).toHaveBeenCalledWith(
276+
expect.objectContaining({ fetchImpl: proxyFetch }),
277+
);
278+
});
215279
});
216280

217281
describe("resolveDiscordMessageText", () => {

src/discord/monitor/message-utils.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ChannelType, Client, Message } from "@buape/carbon";
22
import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10";
33
import { buildMediaPayload } from "../../channels/plugins/media-payload.js";
44
import { logVerbose } from "../../globals.js";
5-
import { fetchRemoteMedia } from "../../media/fetch.js";
5+
import { fetchRemoteMedia, type FetchLike } from "../../media/fetch.js";
66
import { saveMediaBuffer } from "../../media/store.js";
77

88
export type DiscordMediaInfo = {
@@ -161,26 +161,30 @@ export function hasDiscordMessageStickers(message: Message): boolean {
161161
export async function resolveMediaList(
162162
message: Message,
163163
maxBytes: number,
164+
fetchImpl?: FetchLike,
164165
): Promise<DiscordMediaInfo[]> {
165166
const out: DiscordMediaInfo[] = [];
166167
await appendResolvedMediaFromAttachments({
167168
attachments: message.attachments ?? [],
168169
maxBytes,
169170
out,
170171
errorPrefix: "discord: failed to download attachment",
172+
fetchImpl,
171173
});
172174
await appendResolvedMediaFromStickers({
173175
stickers: resolveDiscordMessageStickers(message),
174176
maxBytes,
175177
out,
176178
errorPrefix: "discord: failed to download sticker",
179+
fetchImpl,
177180
});
178181
return out;
179182
}
180183

181184
export async function resolveForwardedMediaList(
182185
message: Message,
183186
maxBytes: number,
187+
fetchImpl?: FetchLike,
184188
): Promise<DiscordMediaInfo[]> {
185189
const snapshots = resolveDiscordMessageSnapshots(message);
186190
if (snapshots.length === 0) {
@@ -193,12 +197,14 @@ export async function resolveForwardedMediaList(
193197
maxBytes,
194198
out,
195199
errorPrefix: "discord: failed to download forwarded attachment",
200+
fetchImpl,
196201
});
197202
await appendResolvedMediaFromStickers({
198203
stickers: snapshot.message ? resolveDiscordSnapshotStickers(snapshot.message) : [],
199204
maxBytes,
200205
out,
201206
errorPrefix: "discord: failed to download forwarded sticker",
207+
fetchImpl,
202208
});
203209
}
204210
return out;
@@ -209,6 +215,7 @@ async function appendResolvedMediaFromAttachments(params: {
209215
maxBytes: number;
210216
out: DiscordMediaInfo[];
211217
errorPrefix: string;
218+
fetchImpl?: FetchLike;
212219
}) {
213220
const attachments = params.attachments;
214221
if (!attachments || attachments.length === 0) {
@@ -220,6 +227,7 @@ async function appendResolvedMediaFromAttachments(params: {
220227
url: attachment.url,
221228
filePathHint: attachment.filename ?? attachment.url,
222229
maxBytes: params.maxBytes,
230+
fetchImpl: params.fetchImpl,
223231
});
224232
const saved = await saveMediaBuffer(
225233
fetched.buffer,
@@ -296,6 +304,7 @@ async function appendResolvedMediaFromStickers(params: {
296304
maxBytes: number;
297305
out: DiscordMediaInfo[];
298306
errorPrefix: string;
307+
fetchImpl?: FetchLike;
299308
}) {
300309
const stickers = params.stickers;
301310
if (!stickers || stickers.length === 0) {
@@ -310,6 +319,7 @@ async function appendResolvedMediaFromStickers(params: {
310319
url: candidate.url,
311320
filePathHint: candidate.fileName,
312321
maxBytes: params.maxBytes,
322+
fetchImpl: params.fetchImpl,
313323
});
314324
const saved = await saveMediaBuffer(
315325
fetched.buffer,

src/discord/monitor/model-picker.test.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,28 @@ describe("Discord model picker custom_id", () => {
117117
});
118118
});
119119

120+
it("parses compact custom_id aliases", () => {
121+
const parsed = parseDiscordModelPickerData({
122+
c: "models",
123+
a: "submit",
124+
v: "models",
125+
u: "42",
126+
p: "openai",
127+
g: "3",
128+
mi: "2",
129+
});
130+
131+
expect(parsed).toEqual({
132+
command: "models",
133+
action: "submit",
134+
view: "models",
135+
userId: "42",
136+
provider: "openai",
137+
page: 3,
138+
modelIndex: 2,
139+
});
140+
});
141+
120142
it("parses optional submit model index", () => {
121143
const parsed = parseDiscordModelPickerData({
122144
cmd: "models",
@@ -179,6 +201,21 @@ describe("Discord model picker custom_id", () => {
179201
}),
180202
).toThrow(/custom_id exceeds/i);
181203
});
204+
205+
it("keeps typical submit ids under Discord max length", () => {
206+
const customId = buildDiscordModelPickerCustomId({
207+
command: "models",
208+
action: "submit",
209+
view: "models",
210+
provider: "azure-openai-responses",
211+
page: 1,
212+
providerPage: 1,
213+
modelIndex: 10,
214+
userId: "12345678901234567890",
215+
});
216+
217+
expect(customId.length).toBeLessThanOrEqual(DISCORD_CUSTOM_ID_MAX_CHARS);
218+
});
182219
});
183220

184221
describe("provider paging", () => {
@@ -325,7 +362,7 @@ describe("Discord model picker rendering", () => {
325362
return parsed?.action === "provider";
326363
});
327364
expect(providerButtons).toHaveLength(Object.keys(entries).length);
328-
expect(allButtons.some((component) => (component.custom_id ?? "").includes(":act=nav:"))).toBe(
365+
expect(allButtons.some((component) => (component.custom_id ?? "").includes(";a=nav;"))).toBe(
329366
false,
330367
);
331368
});
@@ -352,7 +389,7 @@ describe("Discord model picker rendering", () => {
352389
expect(rows.length).toBeGreaterThan(0);
353390

354391
const allButtons = rows.flatMap((row) => row.components ?? []);
355-
expect(allButtons.some((component) => (component.custom_id ?? "").includes(":act=nav:"))).toBe(
392+
expect(allButtons.some((component) => (component.custom_id ?? "").includes(";a=nav;"))).toBe(
356393
false,
357394
);
358395
});

0 commit comments

Comments
 (0)