Skip to content

Commit 3980eaa

Browse files
fix(discord): harden Carbon parity (#75363)
Summary: - The PR updates the Discord plugin with REST priority lanes and stale background drops, gateway send/member v ... normalization, explicit component forwarding, one-off component wait helpers, tests, and a changelog entry. - Reproducibility: yes. The prior scheduler failure has a high-confidence source-level reproduction using one ... ne pending same-bucket request under fake timers, and the latest head adds a regression test for that path. ClawSweeper fixups: - Included follow-up commit: fix(discord): preserve option localizations - Included follow-up commit: fix(discord): harden component sends - Included follow-up commit: docs(changelog): note Discord Carbon parity hardening - Included follow-up commit: fix(discord): harden Carbon parity - Ran the ClawSweeper repair loop before final review. Validation: - ClawSweeper review passed for head 4fa634e. - Required merge gates passed before the squash merge. Prepared head SHA: 4fa634e Review: #75363 (comment) Co-authored-by: Peter Steinberger <[email protected]> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
1 parent cd84e9b commit 3980eaa

14 files changed

Lines changed: 933 additions & 58 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ Docs: https://docs.openclaw.ai
9898
- WhatsApp/Cron: keep DM pairing-store approvals out of implicit cron and heartbeat recipient fallback, so scheduled automation only uses explicit targets, active configured recipients, or configured `allowFrom` entries. Fixes #62339. Thanks @kelvinisly-collab.
9999
- Google Meet: keep the agent-facing `google_meet` tool visible on non-macOS hosts but block local Chrome realtime actions with guidance, so Linux agents can still use transcribe, Twilio, chrome-node, and artifact flows without choosing the macOS-only BlackHole path. Refs #75950. Thanks @actual-software-inc.
100100
- macOS/settings: keep opening General from rewriting `openclaw.json` during Tailscale settings hydration, preserving `gateway`, `auth`, `meta`, and `wizard` until the user changes a setting. Fixes #59545. Thanks @Tengdw.
101+
- Discord: prioritize interaction callbacks ahead of stale background REST work without polling active REST buckets, validate oversized gateway payloads and member-intent requests before send, and forward explicit component payloads from message actions. (#75363)
101102
- Active Memory: use the configured recall timeout as the blocking prompt-build hook budget by default and move cold-start setup grace behind explicit `setupGraceTimeoutMs` config, so the plugin no longer silently extends 15000 ms configs to 45000 ms on the main lane. Fixes #75843. Thanks @vishutdhar.
102103
- Plugins/web-provider: reuse the active gateway plugin registry for runtime web provider resolution after deriving the same candidate plugin ids as the loader path, avoiding a redundant `loadOpenClawPlugins` call on every request while preserving origin and scope filters. Fixes #75513. Thanks @jochen.
103104
- Crestodian/CLI: exit non-zero when interactive Crestodian is invoked without a TTY, so scripts and CI no longer treat the setup error as success. Fixes #73646 and supersedes #73928 and #74059. Thanks @bittoby, @luyao618, and @Linux2010.

extensions/discord/src/actions/handle-action.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,36 @@ describe("handleDiscordMessageAction", () => {
216216
expect(handleDiscordActionMock).not.toHaveBeenCalled();
217217
});
218218

219+
it("forwards top-level components on sends", async () => {
220+
const components = { blocks: [{ type: "text", text: "Pick one" }] };
221+
222+
await handleDiscordMessageAction({
223+
action: "send",
224+
params: {
225+
message: "hello",
226+
components,
227+
},
228+
cfg: {
229+
channels: { discord: { token: "tok" } },
230+
} as OpenClawConfig,
231+
toolContext: {
232+
currentChannelProvider: "discord",
233+
currentChannelId: "channel:123",
234+
},
235+
});
236+
237+
expect(handleDiscordActionMock).toHaveBeenCalledWith(
238+
expect.objectContaining({
239+
action: "sendMessage",
240+
to: "channel:123",
241+
content: "hello",
242+
components,
243+
}),
244+
expect.any(Object),
245+
expect.any(Object),
246+
);
247+
});
248+
219249
it("does not use another provider's current target for Discord sends", async () => {
220250
await expect(
221251
handleDiscordMessageAction({

extensions/discord/src/actions/handle-action.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export async function handleDiscordMessageAction(
8181
const to = readSendTarget();
8282
const asVoice = readBooleanParam(params, "asVoice") === true;
8383
const rawComponents =
84+
params.components ??
8485
buildDiscordPresentationComponents(normalizeMessagePresentation(params.presentation)) ??
8586
buildDiscordInteractiveComponents(normalizeInteractiveReply(params.interactive));
8687
const hasComponents =

extensions/discord/src/internal/client.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,117 @@ describe("Client.deployCommands", () => {
150150
expect(deleteRequest).not.toHaveBeenCalled();
151151
});
152152

153+
it("does not patch live-only command metadata or reordered unordered arrays", async () => {
154+
const client = createInternalTestClient([
155+
createTestCommand({
156+
name: "one",
157+
options: [
158+
{
159+
type: 3,
160+
name: "value",
161+
description: "Value",
162+
required: false,
163+
autocomplete: false,
164+
channel_types: [1, 0],
165+
},
166+
],
167+
}),
168+
]);
169+
const get = vi.fn(async () => [
170+
{
171+
id: "cmd1",
172+
application_id: "app1",
173+
type: ApplicationCommandType.ChatInput,
174+
name: "one",
175+
name_localized: "one",
176+
description: "one command",
177+
description_localized: "one command",
178+
options: [
179+
{
180+
type: 3,
181+
name: "value",
182+
description: "Value",
183+
description_localized: "Value",
184+
channel_types: [0, 1],
185+
},
186+
],
187+
default_member_permissions: null,
188+
dm_permission: true,
189+
integration_types: [1, 0],
190+
contexts: [2, 1, 0],
191+
guild_id: undefined,
192+
version: "1",
193+
},
194+
]);
195+
const patch = vi.fn(async () => undefined);
196+
const post = vi.fn(async () => undefined);
197+
const deleteRequest = vi.fn(async () => undefined);
198+
attachRestMock(client, { get, patch, post, delete: deleteRequest });
199+
200+
await client.deployCommands({ mode: "reconcile" });
201+
202+
expect(patch).not.toHaveBeenCalled();
203+
expect(post).not.toHaveBeenCalled();
204+
expect(deleteRequest).not.toHaveBeenCalled();
205+
});
206+
207+
it("patches changed option localization maps", async () => {
208+
const client = createInternalTestClient([
209+
createTestCommand({
210+
name: "one",
211+
options: [
212+
{
213+
type: 3,
214+
name: "value",
215+
name_localizations: { de: "wert" },
216+
description: "Value",
217+
description_localizations: { de: "Wert" },
218+
},
219+
],
220+
}),
221+
]);
222+
const get = vi.fn(async () => [
223+
{
224+
id: "cmd1",
225+
application_id: "app1",
226+
type: ApplicationCommandType.ChatInput,
227+
name: "one",
228+
description: "one command",
229+
options: [
230+
{
231+
type: 3,
232+
name: "value",
233+
name_localizations: { de: "alter-wert" },
234+
description: "Value",
235+
description_localizations: { de: "Alter Wert" },
236+
},
237+
],
238+
},
239+
]);
240+
const patch = vi.fn(async () => undefined);
241+
const post = vi.fn(async () => undefined);
242+
const deleteRequest = vi.fn(async () => undefined);
243+
attachRestMock(client, { get, patch, post, delete: deleteRequest });
244+
245+
await client.deployCommands({ mode: "reconcile" });
246+
247+
expect(patch).toHaveBeenCalledWith(
248+
Routes.applicationCommand("app1", "cmd1"),
249+
expect.objectContaining({
250+
body: expect.objectContaining({
251+
options: [
252+
expect.objectContaining({
253+
name_localizations: { de: "wert" },
254+
description_localizations: { de: "Wert" },
255+
}),
256+
],
257+
}),
258+
}),
259+
);
260+
expect(post).not.toHaveBeenCalled();
261+
expect(deleteRequest).not.toHaveBeenCalled();
262+
});
263+
153264
it("skips command deploy when the serialized command set is unchanged", async () => {
154265
const client = createInternalTestClient([createTestCommand({ name: "one" })]);
155266
const get = vi.fn(async () => []);

extensions/discord/src/internal/client.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { DiscordEntityCache } from "./entity-cache.js";
66
import { DiscordEventQueue, type DiscordEventQueueOptions } from "./event-queue.js";
77
import { dispatchInteraction } from "./interaction-dispatch.js";
88
import { RequestClient, type RequestClientOptions } from "./rest.js";
9-
import type { Guild, GuildMember, User } from "./structures.js";
9+
import type { Guild, GuildMember, Message, User } from "./structures.js";
1010

1111
export interface Route {
1212
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
@@ -49,10 +49,18 @@ export interface ClientOptions {
4949
restCacheTtlMs?: number;
5050
}
5151

52+
type OneOffComponentResult =
53+
| { success: true; customId: string; message: Message; values?: string[] }
54+
| { success: false; message: Message; reason: "timed out" };
55+
5256
export class ComponentRegistry<
5357
T extends { customId: string; customIdParser?: typeof parseCustomId; type?: number },
5458
> {
5559
private entries = new Map<string, T[]>();
60+
private oneOffComponents = new Map<
61+
string,
62+
{ message: Message; resolve(result: OneOffComponentResult): void; timer: NodeJS.Timeout }
63+
>();
5664
private wildcardEntries: T[] = [];
5765

5866
register(entry: T): void {
@@ -90,12 +98,66 @@ export class ComponentRegistry<
9098
return true;
9199
});
92100
}
101+
102+
waitForMessageComponent(message: Message, timeoutMs: number): Promise<OneOffComponentResult> {
103+
const key = createOneOffComponentKey(message.id, message.channelId);
104+
return new Promise((resolve) => {
105+
const existing = this.oneOffComponents.get(key);
106+
if (existing) {
107+
clearTimeout(existing.timer);
108+
existing.resolve({ success: false, message, reason: "timed out" });
109+
}
110+
const timer = setTimeout(
111+
() => {
112+
this.oneOffComponents.delete(key);
113+
resolve({ success: false, message, reason: "timed out" });
114+
},
115+
Math.max(0, timeoutMs),
116+
);
117+
timer.unref?.();
118+
this.oneOffComponents.set(key, {
119+
message,
120+
timer,
121+
resolve,
122+
});
123+
});
124+
}
125+
126+
resolveOneOffComponent(params: {
127+
channelId?: string;
128+
customId: string;
129+
messageId?: string;
130+
values?: string[];
131+
}): boolean {
132+
if (!params.messageId || !params.channelId) {
133+
return false;
134+
}
135+
const entry = this.oneOffComponents.get(
136+
createOneOffComponentKey(params.messageId, params.channelId),
137+
);
138+
if (!entry) {
139+
return false;
140+
}
141+
clearTimeout(entry.timer);
142+
this.oneOffComponents.delete(createOneOffComponentKey(params.messageId, params.channelId));
143+
entry.resolve({
144+
success: true,
145+
customId: params.customId,
146+
message: entry.message,
147+
values: params.values,
148+
});
149+
return true;
150+
}
93151
}
94152

95153
function parseRegistryKey(customId: string, parser: typeof parseCustomId = parseCustomId): string {
96154
return parser(customId).key;
97155
}
98156

157+
function createOneOffComponentKey(messageId: string, channelId: string): string {
158+
return `${messageId}:${channelId}`;
159+
}
160+
99161
export class Client {
100162
routes: Route[] = [];
101163
plugins: Array<{ id: string; plugin: Plugin }> = [];

extensions/discord/src/internal/command-deploy.ts

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -157,12 +157,15 @@ function comparableCommand(value: unknown): unknown {
157157
return value;
158158
}
159159
const omit = new Set([
160-
"id",
161160
"application_id",
161+
"description_localized",
162+
"dm_permission",
162163
"guild_id",
164+
"id",
165+
"name_localized",
166+
"nsfw",
163167
"version",
164168
"default_permission",
165-
"nsfw",
166169
]);
167170
return stableComparableObject(
168171
Object.fromEntries(
@@ -171,18 +174,50 @@ function comparableCommand(value: unknown): unknown {
171174
);
172175
}
173176

174-
function stableComparableObject(value: unknown): unknown {
177+
const unorderedCommandArrayFields = new Set(["channel_types", "contexts", "integration_types"]);
178+
const optionComparisonOmittedFields = new Set([
179+
"contexts",
180+
"default_member_permissions",
181+
"description_localized",
182+
"integration_types",
183+
"name_localized",
184+
]);
185+
186+
function stableComparableObject(value: unknown, path: string[] = []): unknown {
175187
if (Array.isArray(value)) {
176-
return value.map((entry) => stableComparableObject(entry));
188+
const normalized = value.map((entry) => stableComparableObject(entry, path));
189+
const key = path.at(-1);
190+
if (
191+
key &&
192+
unorderedCommandArrayFields.has(key) &&
193+
normalized.every(
194+
(entry) =>
195+
typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean",
196+
)
197+
) {
198+
return normalized.toSorted((left, right) => String(left).localeCompare(String(right)));
199+
}
200+
return normalized;
177201
}
178202
if (!value || typeof value !== "object") {
179203
return value;
180204
}
181205
return Object.fromEntries(
182206
Object.entries(value as Record<string, unknown>)
183-
.filter(([, entry]) => entry !== undefined)
207+
.filter(([key, entry]) => {
208+
if (entry === undefined) {
209+
return false;
210+
}
211+
if (path.includes("options") && optionComparisonOmittedFields.has(key)) {
212+
return false;
213+
}
214+
if ((key === "required" || key === "autocomplete") && entry === false) {
215+
return false;
216+
}
217+
return true;
218+
})
184219
.toSorted(([a], [b]) => a.localeCompare(b))
185-
.map(([key, entry]) => [key, stableComparableObject(entry)]),
220+
.map(([key, entry]) => [key, stableComparableObject(entry, [...path, key])]),
186221
);
187222
}
188223

0 commit comments

Comments
 (0)