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 <openperf@users.noreply.github.com>
Co-authored-by: chilu18 <chilu18@users.noreply.github.com>
Co-authored-by: Yipsh <Yipsh@users.noreply.github.com>
Co-authored-by: lbo728 <lbo728@users.noreply.github.com>
Co-authored-by: s1korrrr <s1korrrr@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-02-25 00:03:21 +00:00
parent 55cf92578d
commit 97e56cb73c
12 changed files with 265 additions and 23 deletions

View File

@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.

View File

@@ -1,5 +1,7 @@
import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
import type { APIGatewayBotInfo } from "discord-api-types/v10";
import { HttpsProxyAgent } from "https-proxy-agent";
import { ProxyAgent, fetch as undiciFetch } from "undici";
import WebSocket from "ws";
import type { DiscordAccountConfig } from "../../config/types.js";
import { danger } from "../../globals.js";
@@ -42,7 +44,8 @@ export function createDiscordGatewayPlugin(params: {
}
try {
const agent = new HttpsProxyAgent<string>(proxy);
const wsAgent = new HttpsProxyAgent<string>(proxy);
const fetchAgent = new ProxyAgent(proxy);
params.runtime.log?.("discord: gateway proxy enabled");
@@ -51,8 +54,28 @@ export function createDiscordGatewayPlugin(params: {
super(options);
}
createWebSocket(url: string) {
return new WebSocket(url, { agent });
override async registerClient(client: Parameters<GatewayPlugin["registerClient"]>[0]) {
if (!this.gatewayInfo) {
try {
const response = await undiciFetch("https://discord.com/api/v10/gateway/bot", {
headers: {
Authorization: `Bot ${client.options.token}`,
},
dispatcher: fetchAgent,
} as Record<string, unknown>);
this.gatewayInfo = (await response.json()) as APIGatewayBotInfo;
} catch (error) {
throw new Error(
`Failed to get gateway information from Discord: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
}
return super.registerClient(client);
}
override createWebSocket(url: string) {
return new WebSocket(url, { agent: wsAgent });
}
}

View File

@@ -733,5 +733,6 @@ export async function preflightDiscordMessage(
canDetectMention,
historyEntry,
threadBindings: params.threadBindings,
discordRestFetch: params.discordRestFetch,
};
}

View File

@@ -84,6 +84,7 @@ export type DiscordMessagePreflightContext = {
historyEntry?: HistoryEntry;
threadBindings: ThreadBindingManager;
discordRestFetch?: typeof fetch;
};
export type DiscordMessagePreflightParams = {
@@ -106,6 +107,7 @@ export type DiscordMessagePreflightParams = {
ackReactionScope: DiscordMessagePreflightContext["ackReactionScope"];
groupPolicy: DiscordMessagePreflightContext["groupPolicy"];
threadBindings: ThreadBindingManager;
discordRestFetch?: typeof fetch;
data: DiscordMessageEvent;
client: Client;
};

View File

@@ -257,6 +257,35 @@ describe("processDiscordMessage ack reactions", () => {
expect(emojis).toContain(DEFAULT_EMOJIS.stallHard);
expect(emojis).toContain(DEFAULT_EMOJIS.done);
});
it("applies status reaction emoji/timing overrides from config", async () => {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onReasoningStream?.();
return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } };
});
const ctx = await createBaseContext({
cfg: {
messages: {
ackReaction: "👀",
statusReactions: {
emojis: { queued: "🟦", thinking: "🧪", done: "🏁" },
timing: { debounceMs: 0 },
},
},
session: { store: "/tmp/openclaw-discord-process-test-sessions.json" },
},
});
// oxlint-disable-next-line typescript/no-explicit-any
await processDiscordMessage(ctx as any);
const emojis = (
sendMocks.reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]>
).map((call) => call[2]);
expect(emojis).toContain("🟦");
expect(emojis).toContain("🏁");
});
});
describe("processDiscordMessage session routing", () => {

View File

@@ -101,10 +101,15 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
threadBindings,
route,
commandAuthorized,
discordRestFetch,
} = ctx;
const mediaList = await resolveMediaList(message, mediaMaxBytes);
const forwardedMediaList = await resolveForwardedMediaList(message, mediaMaxBytes);
const mediaList = await resolveMediaList(message, mediaMaxBytes, discordRestFetch);
const forwardedMediaList = await resolveForwardedMediaList(
message,
mediaMaxBytes,
discordRestFetch,
);
mediaList.push(...forwardedMediaList);
const text = messageText;
if (!text) {
@@ -147,6 +152,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
enabled: statusReactionsEnabled,
adapter: discordAdapter,
initialEmoji: ackReaction,
emojis: cfg.messages?.statusReactions?.emojis,
timing: cfg.messages?.statusReactions?.timing,
onError: (err) => {
logAckFailure({
log: logVerbose,

View File

@@ -93,6 +93,7 @@ describe("resolveForwardedMediaList", () => {
url: attachment.url,
filePathHint: attachment.filename,
maxBytes: 512,
fetchImpl: undefined,
});
expect(saveMediaBuffer).toHaveBeenCalledTimes(1);
expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512);
@@ -105,6 +106,38 @@ describe("resolveForwardedMediaList", () => {
]);
});
it("forwards fetchImpl to forwarded attachment downloads", async () => {
const proxyFetch = vi.fn() as unknown as typeof fetch;
const attachment = {
id: "att-proxy",
url: "https://cdn.discordapp.com/attachments/1/proxy.png",
filename: "proxy.png",
content_type: "image/png",
};
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("image"),
contentType: "image/png",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/proxy.png",
contentType: "image/png",
});
await resolveForwardedMediaList(
asMessage({
rawData: {
message_snapshots: [{ message: { attachments: [attachment] } }],
},
}),
512,
proxyFetch,
);
expect(fetchRemoteMedia).toHaveBeenCalledWith(
expect.objectContaining({ fetchImpl: proxyFetch }),
);
});
it("downloads forwarded stickers", async () => {
const sticker = {
id: "sticker-1",
@@ -134,6 +167,7 @@ describe("resolveForwardedMediaList", () => {
url: "https://media.discordapp.net/stickers/sticker-1.png",
filePathHint: "wave.png",
maxBytes: 512,
fetchImpl: undefined,
});
expect(saveMediaBuffer).toHaveBeenCalledTimes(1);
expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512);
@@ -201,6 +235,7 @@ describe("resolveMediaList", () => {
url: "https://media.discordapp.net/stickers/sticker-2.png",
filePathHint: "hello.png",
maxBytes: 512,
fetchImpl: undefined,
});
expect(saveMediaBuffer).toHaveBeenCalledTimes(1);
expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512);
@@ -212,6 +247,35 @@ describe("resolveMediaList", () => {
},
]);
});
it("forwards fetchImpl to sticker downloads", async () => {
const proxyFetch = vi.fn() as unknown as typeof fetch;
const sticker = {
id: "sticker-proxy",
name: "proxy-sticker",
format_type: StickerFormatType.PNG,
};
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("sticker"),
contentType: "image/png",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/sticker-proxy.png",
contentType: "image/png",
});
await resolveMediaList(
asMessage({
stickers: [sticker],
}),
512,
proxyFetch,
);
expect(fetchRemoteMedia).toHaveBeenCalledWith(
expect.objectContaining({ fetchImpl: proxyFetch }),
);
});
});
describe("resolveDiscordMessageText", () => {

View File

@@ -2,7 +2,7 @@ import type { ChannelType, Client, Message } from "@buape/carbon";
import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10";
import { buildMediaPayload } from "../../channels/plugins/media-payload.js";
import { logVerbose } from "../../globals.js";
import { fetchRemoteMedia } from "../../media/fetch.js";
import { fetchRemoteMedia, type FetchLike } from "../../media/fetch.js";
import { saveMediaBuffer } from "../../media/store.js";
export type DiscordMediaInfo = {
@@ -161,6 +161,7 @@ export function hasDiscordMessageStickers(message: Message): boolean {
export async function resolveMediaList(
message: Message,
maxBytes: number,
fetchImpl?: FetchLike,
): Promise<DiscordMediaInfo[]> {
const out: DiscordMediaInfo[] = [];
await appendResolvedMediaFromAttachments({
@@ -168,12 +169,14 @@ export async function resolveMediaList(
maxBytes,
out,
errorPrefix: "discord: failed to download attachment",
fetchImpl,
});
await appendResolvedMediaFromStickers({
stickers: resolveDiscordMessageStickers(message),
maxBytes,
out,
errorPrefix: "discord: failed to download sticker",
fetchImpl,
});
return out;
}
@@ -181,6 +184,7 @@ export async function resolveMediaList(
export async function resolveForwardedMediaList(
message: Message,
maxBytes: number,
fetchImpl?: FetchLike,
): Promise<DiscordMediaInfo[]> {
const snapshots = resolveDiscordMessageSnapshots(message);
if (snapshots.length === 0) {
@@ -193,12 +197,14 @@ export async function resolveForwardedMediaList(
maxBytes,
out,
errorPrefix: "discord: failed to download forwarded attachment",
fetchImpl,
});
await appendResolvedMediaFromStickers({
stickers: snapshot.message ? resolveDiscordSnapshotStickers(snapshot.message) : [],
maxBytes,
out,
errorPrefix: "discord: failed to download forwarded sticker",
fetchImpl,
});
}
return out;
@@ -209,6 +215,7 @@ async function appendResolvedMediaFromAttachments(params: {
maxBytes: number;
out: DiscordMediaInfo[];
errorPrefix: string;
fetchImpl?: FetchLike;
}) {
const attachments = params.attachments;
if (!attachments || attachments.length === 0) {
@@ -220,6 +227,7 @@ async function appendResolvedMediaFromAttachments(params: {
url: attachment.url,
filePathHint: attachment.filename ?? attachment.url,
maxBytes: params.maxBytes,
fetchImpl: params.fetchImpl,
});
const saved = await saveMediaBuffer(
fetched.buffer,
@@ -296,6 +304,7 @@ async function appendResolvedMediaFromStickers(params: {
maxBytes: number;
out: DiscordMediaInfo[];
errorPrefix: string;
fetchImpl?: FetchLike;
}) {
const stickers = params.stickers;
if (!stickers || stickers.length === 0) {
@@ -310,6 +319,7 @@ async function appendResolvedMediaFromStickers(params: {
url: candidate.url,
filePathHint: candidate.fileName,
maxBytes: params.maxBytes,
fetchImpl: params.fetchImpl,
});
const saved = await saveMediaBuffer(
fetched.buffer,

View File

@@ -117,6 +117,28 @@ describe("Discord model picker custom_id", () => {
});
});
it("parses compact custom_id aliases", () => {
const parsed = parseDiscordModelPickerData({
c: "models",
a: "submit",
v: "models",
u: "42",
p: "openai",
g: "3",
mi: "2",
});
expect(parsed).toEqual({
command: "models",
action: "submit",
view: "models",
userId: "42",
provider: "openai",
page: 3,
modelIndex: 2,
});
});
it("parses optional submit model index", () => {
const parsed = parseDiscordModelPickerData({
cmd: "models",
@@ -179,6 +201,21 @@ describe("Discord model picker custom_id", () => {
}),
).toThrow(/custom_id exceeds/i);
});
it("keeps typical submit ids under Discord max length", () => {
const customId = buildDiscordModelPickerCustomId({
command: "models",
action: "submit",
view: "models",
provider: "azure-openai-responses",
page: 1,
providerPage: 1,
modelIndex: 10,
userId: "12345678901234567890",
});
expect(customId.length).toBeLessThanOrEqual(DISCORD_CUSTOM_ID_MAX_CHARS);
});
});
describe("provider paging", () => {
@@ -325,7 +362,7 @@ describe("Discord model picker rendering", () => {
return parsed?.action === "provider";
});
expect(providerButtons).toHaveLength(Object.keys(entries).length);
expect(allButtons.some((component) => (component.custom_id ?? "").includes(":act=nav:"))).toBe(
expect(allButtons.some((component) => (component.custom_id ?? "").includes(";a=nav;"))).toBe(
false,
);
});
@@ -352,7 +389,7 @@ describe("Discord model picker rendering", () => {
expect(rows.length).toBeGreaterThan(0);
const allButtons = rows.flatMap((row) => row.components ?? []);
expect(allButtons.some((component) => (component.custom_id ?? "").includes(":act=nav:"))).toBe(
expect(allButtons.some((component) => (component.custom_id ?? "").includes(";a=nav;"))).toBe(
false,
);
});

View File

@@ -577,11 +577,11 @@ export function buildDiscordModelPickerCustomId(params: {
: undefined;
const parts = [
`${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:cmd=${encodeCustomIdValue(params.command)}`,
`act=${encodeCustomIdValue(params.action)}`,
`view=${encodeCustomIdValue(params.view)}`,
`${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:c=${encodeCustomIdValue(params.command)}`,
`a=${encodeCustomIdValue(params.action)}`,
`v=${encodeCustomIdValue(params.view)}`,
`u=${encodeCustomIdValue(userId)}`,
`pg=${String(page)}`,
`g=${String(page)}`,
];
if (normalizedProvider) {
parts.push(`p=${encodeCustomIdValue(normalizedProvider)}`);
@@ -635,12 +635,12 @@ export function parseDiscordModelPickerData(data: ComponentData): DiscordModelPi
return null;
}
const command = decodeCustomIdValue(coerceString(data.cmd));
const action = decodeCustomIdValue(coerceString(data.act));
const view = decodeCustomIdValue(coerceString(data.view));
const command = decodeCustomIdValue(coerceString(data.c ?? data.cmd));
const action = decodeCustomIdValue(coerceString(data.a ?? data.act));
const view = decodeCustomIdValue(coerceString(data.v ?? data.view));
const userId = decodeCustomIdValue(coerceString(data.u));
const providerRaw = decodeCustomIdValue(coerceString(data.p));
const page = parseRawPage(data.pg);
const page = parseRawPage(data.g ?? data.pg);
const providerPage = parseRawPositiveInt(data.pp);
const modelIndex = parseRawPositiveInt(data.mi);
const recentSlot = parseRawPositiveInt(data.rs);

View File

@@ -2,14 +2,22 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const {
GatewayIntents,
baseRegisterClientSpy,
GatewayPlugin,
HttpsProxyAgent,
getLastAgent,
proxyAgentSpy,
restProxyAgentSpy,
undiciFetchMock,
undiciProxyAgentSpy,
resetLastAgent,
webSocketSpy,
wsProxyAgentSpy,
} = vi.hoisted(() => {
const proxyAgentSpy = vi.fn();
const wsProxyAgentSpy = vi.fn();
const undiciProxyAgentSpy = vi.fn();
const restProxyAgentSpy = vi.fn();
const undiciFetchMock = vi.fn();
const baseRegisterClientSpy = vi.fn();
const webSocketSpy = vi.fn();
const GatewayIntents = {
@@ -23,7 +31,17 @@ const {
GuildMembers: 1 << 7,
} as const;
class GatewayPlugin {}
class GatewayPlugin {
options: unknown;
gatewayInfo: unknown;
constructor(options?: unknown, gatewayInfo?: unknown) {
this.options = options;
this.gatewayInfo = gatewayInfo;
}
async registerClient(client: unknown) {
baseRegisterClientSpy(client);
}
}
class HttpsProxyAgent {
static lastCreated: HttpsProxyAgent | undefined;
@@ -34,20 +52,24 @@ const {
}
this.proxyUrl = proxyUrl;
HttpsProxyAgent.lastCreated = this;
proxyAgentSpy(proxyUrl);
wsProxyAgentSpy(proxyUrl);
}
}
return {
baseRegisterClientSpy,
GatewayIntents,
GatewayPlugin,
HttpsProxyAgent,
getLastAgent: () => HttpsProxyAgent.lastCreated,
proxyAgentSpy,
restProxyAgentSpy,
undiciFetchMock,
undiciProxyAgentSpy,
resetLastAgent: () => {
HttpsProxyAgent.lastCreated = undefined;
},
webSocketSpy,
wsProxyAgentSpy,
};
});
@@ -61,6 +83,18 @@ vi.mock("https-proxy-agent", () => ({
HttpsProxyAgent,
}));
vi.mock("undici", () => ({
ProxyAgent: class {
proxyUrl: string;
constructor(proxyUrl: string) {
this.proxyUrl = proxyUrl;
undiciProxyAgentSpy(proxyUrl);
restProxyAgentSpy(proxyUrl);
}
},
fetch: undiciFetchMock,
}));
vi.mock("ws", () => ({
default: class MockWebSocket {
constructor(url: string, options?: { agent?: unknown }) {
@@ -87,7 +121,11 @@ describe("createDiscordGatewayPlugin", () => {
}
beforeEach(() => {
proxyAgentSpy.mockClear();
baseRegisterClientSpy.mockClear();
restProxyAgentSpy.mockClear();
undiciFetchMock.mockClear();
undiciProxyAgentSpy.mockClear();
wsProxyAgentSpy.mockClear();
webSocketSpy.mockClear();
resetLastAgent();
});
@@ -106,7 +144,7 @@ describe("createDiscordGatewayPlugin", () => {
.createWebSocket;
createWebSocket("wss://gateway.discord.gg");
expect(proxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080");
expect(wsProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080");
expect(webSocketSpy).toHaveBeenCalledWith(
"wss://gateway.discord.gg",
expect.objectContaining({ agent: getLastAgent() }),
@@ -127,4 +165,33 @@ describe("createDiscordGatewayPlugin", () => {
expect(runtime.error).toHaveBeenCalled();
expect(runtime.log).not.toHaveBeenCalled();
});
it("uses proxy fetch for gateway metadata lookup before registering", async () => {
const runtime = createRuntime();
undiciFetchMock.mockResolvedValue({
json: async () => ({ url: "wss://gateway.discord.gg" }),
} as Response);
const plugin = createDiscordGatewayPlugin({
discordConfig: { proxy: "http://proxy.test:8080" },
runtime,
});
await (
plugin as unknown as {
registerClient: (client: { options: { token: string } }) => Promise<void>;
}
).registerClient({
options: { token: "token-123" },
});
expect(restProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080");
expect(undiciFetchMock).toHaveBeenCalledWith(
"https://discord.com/api/v10/gateway/bot",
expect.objectContaining({
headers: { Authorization: "Bot token-123" },
dispatcher: expect.objectContaining({ proxyUrl: "http://proxy.test:8080" }),
}),
);
expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -550,6 +550,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
allowFrom,
guildEntries,
threadBindings,
discordRestFetch,
});
registerDiscordListener(client.listeners, new DiscordMessageListener(messageHandler, logger));