fix(discord): ignore stale exec approval clicks

This commit is contained in:
Peter Steinberger
2026-04-26 09:59:45 +01:00
parent 57a77ecdf9
commit 775c61ef5f
3 changed files with 96 additions and 12 deletions

View File

@@ -6,6 +6,18 @@ Docs: https://docs.openclaw.ai
## 2026.4.26
### Fixes
- Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse.
- Gateway/Tailscale: let Tailscale-authenticated Control UI operator sessions with browser device identity skip the device-pairing round trip while still rejecting device-less and node-role connections. Refs #71986. Thanks @jokedul.
- Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd.
- Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex.
- Package: include patched dependency files in the published npm package so downstream installs can resolve `patchedDependencies`. (#69224) Thanks @gucasbrg and @vincentkoc.
- Plugins/channels: treat malformed bundled channel plugin loaders that return `undefined` as unavailable instead of crashing config and help paths. Fixes #69044. Thanks @frankhli843 and @vincentkoc.
- Scripts/watch: show corrupted dependency package-config recovery guidance when `gateway:watch` fails during watcher startup, without double-logging unrelated import failures. (#58780) Thanks @roytong9 and @vincentkoc.
- Signal: read signal-cli RPC, health checks, and SSE events through Node's HTTP client so Node 24/25 fetch regressions do not break Signal sends or inbound events. Fixes #51716 and #53040. Thanks @Barukimang, @minupla, and @vincentkoc.
- Skills/Docker: run npm-backed skill dependency installs with an OpenClaw-managed user prefix so non-root Docker images do not write to `/usr/local`. Fixes #59601. Thanks @chanjarster and @vincentkoc.
## 2026.4.25
### Highlights

View File

@@ -87,7 +87,7 @@ describe("discord exec approval monitor helpers", () => {
const interaction = createInteraction();
const button = new ExecApprovalButton({
getApprovers: () => ["123"],
resolveApproval: async () => true,
resolveApproval: async () => ({ ok: true }),
});
await button.run(interaction, { id: "", action: "" });
@@ -102,7 +102,7 @@ describe("discord exec approval monitor helpers", () => {
const interaction = createInteraction({ userId: "999" });
const button = new ExecApprovalButton({
getApprovers: () => ["123"],
resolveApproval: async () => true,
resolveApproval: async () => ({ ok: true }),
});
await button.run(interaction, { id: "abc", action: "allow-once" });
@@ -115,7 +115,7 @@ describe("discord exec approval monitor helpers", () => {
it("acknowledges and resolves valid approval clicks", async () => {
const interaction = createInteraction();
const resolveApproval = vi.fn(async () => true);
const resolveApproval = vi.fn(async () => ({ ok: true }) as const);
const button = new ExecApprovalButton({
getApprovers: () => ["123"],
resolveApproval,
@@ -132,7 +132,7 @@ describe("discord exec approval monitor helpers", () => {
const interaction = createInteraction();
const button = new ExecApprovalButton({
getApprovers: () => ["123"],
resolveApproval: async () => false,
resolveApproval: async () => ({ ok: false, reason: "error" }),
});
await button.run(interaction, { id: "abc", action: "deny" });
@@ -144,6 +144,19 @@ describe("discord exec approval monitor helpers", () => {
});
});
it("keeps already-resolved approval clicks quiet", async () => {
const interaction = createInteraction();
const button = new ExecApprovalButton({
getApprovers: () => ["123"],
resolveApproval: async () => ({ ok: false, reason: "not-found" }),
});
await button.run(interaction, { id: "abc", action: "allow-once" });
expect(interaction.acknowledge).toHaveBeenCalled();
expect(interaction.followUp).not.toHaveBeenCalled();
});
it("builds button context from config and routes resolution over gateway", async () => {
const cfg = buildConfig({ enabled: true, approvers: ["123"] });
resolveApprovalOverGatewayMock.mockResolvedValue(undefined);
@@ -155,7 +168,7 @@ describe("discord exec approval monitor helpers", () => {
});
expect(ctx.getApprovers()).toEqual(["123"]);
await expect(ctx.resolveApproval("abc", "allow-once")).resolves.toBe(true);
await expect(ctx.resolveApproval("abc", "allow-once")).resolves.toEqual({ ok: true });
expect(resolveApprovalOverGatewayMock).toHaveBeenCalledWith({
cfg,
approvalId: "abc",
@@ -173,6 +186,41 @@ describe("discord exec approval monitor helpers", () => {
config: { enabled: true, approvers: ["123"] },
});
await expect(ctx.resolveApproval("abc", "allow-once")).resolves.toBe(false);
await expect(ctx.resolveApproval("abc", "allow-once")).resolves.toEqual({
ok: false,
reason: "error",
});
});
it("classifies structured approval-not-found gateway errors as stale clicks", async () => {
const err = Object.assign(new Error("unknown or expired approval id"), {
gatewayCode: "INVALID_REQUEST",
details: { reason: "APPROVAL_NOT_FOUND" },
});
resolveApprovalOverGatewayMock.mockRejectedValue(err);
const ctx = createDiscordExecApprovalButtonContext({
cfg: buildConfig({ enabled: true, approvers: ["123"] }),
accountId: "default",
config: { enabled: true, approvers: ["123"] },
});
await expect(ctx.resolveApproval("abc", "allow-once")).resolves.toEqual({
ok: false,
reason: "not-found",
});
});
it("keeps message-only approval-not-found errors visible", async () => {
resolveApprovalOverGatewayMock.mockRejectedValue(new Error("unknown or expired approval id"));
const ctx = createDiscordExecApprovalButtonContext({
cfg: buildConfig({ enabled: true, approvers: ["123"] }),
accountId: "default",
config: { enabled: true, approvers: ["123"] },
});
await expect(ctx.resolveApproval("abc", "allow-once")).resolves.toEqual({
ok: false,
reason: "error",
});
});
});

View File

@@ -53,9 +53,30 @@ export function parseExecApprovalData(
export type ExecApprovalButtonContext = {
getApprovers: () => string[];
resolveApproval: (approvalId: string, decision: ExecApprovalDecision) => Promise<boolean>;
resolveApproval: (
approvalId: string,
decision: ExecApprovalDecision,
) => Promise<ExecApprovalResolveResult>;
};
type ExecApprovalResolveResult = { ok: true } | { ok: false; reason: "error" | "not-found" };
function isStructuredApprovalNotFoundError(err: unknown): boolean {
if (!err || typeof err !== "object") {
return false;
}
const record = err as {
gatewayCode?: unknown;
details?: { reason?: unknown } | null;
};
if (record.gatewayCode === "APPROVAL_NOT_FOUND") {
return true;
}
return (
record.gatewayCode === "INVALID_REQUEST" && record.details?.reason === "APPROVAL_NOT_FOUND"
);
}
export class ExecApprovalButton extends Button {
label = "execapproval";
customId = "execapproval:seed=1";
@@ -100,8 +121,8 @@ export class ExecApprovalButton extends Button {
await interaction.acknowledge();
} catch {}
const ok = await this.ctx.resolveApproval(parsed.approvalId, parsed.action);
if (!ok) {
const result = await this.ctx.resolveApproval(parsed.approvalId, parsed.action);
if (!result.ok && result.reason !== "not-found") {
try {
await interaction.followUp({
content: `Failed to submit approval decision for **${decisionLabel}**. The request may have expired or already been resolved.`,
@@ -138,9 +159,12 @@ export function createDiscordExecApprovalButtonContext(params: {
gatewayUrl: params.gatewayUrl,
clientDisplayName: `Discord approval (${params.accountId})`,
});
return true;
} catch {
return false;
return { ok: true };
} catch (err) {
return {
ok: false,
reason: isStructuredApprovalNotFoundError(err) ? "not-found" : "error",
};
}
},
};