Compare commits

...

7 Commits

Author SHA1 Message Date
scoootscooob
c625a3c306 fix(exec): scope telegram legacy approval fallback 2026-03-30 08:33:44 -07:00
scoootscooob
330266545b fix(exec): restore channel approval routing 2026-03-30 08:33:41 -07:00
scoootscooob
e7e27b4994 fix(exec): clean up failed approval deliveries 2026-03-30 08:26:08 -07:00
scoootscooob
454943a1a5 fix(exec): handle approval runtime races 2026-03-30 04:07:11 -07:00
scoootscooob
9d0d99c713 fix(exec): guard approval expiration callbacks 2026-03-30 03:55:39 -07:00
scoootscooob
b0c746d726 fix(exec): harden shared approval runtime 2026-03-30 03:48:09 -07:00
scoootscooob
614c42ac8e fix(exec): add shared approval runtime 2026-03-30 03:39:50 -07:00
24 changed files with 2872 additions and 2131 deletions

View File

@@ -303,6 +303,7 @@ Docs: https://docs.openclaw.ai
- Security/path resolution: prefer non-user-writable absolute helper binaries for OpenClaw CLI, ffmpeg, and OpenSSL resolution so PATH hijacks cannot replace trusted helpers with attacker-controlled executables.
- Security/gateway command scopes: require `operator.admin` before Telegram target writeback and Talk Voice `/voice set` config writes persist through gateway message flows.
- Security/OpenShell mirror: exclude workspace `hooks/` from mirror sync so untrusted sandbox files cannot become trusted host hooks on gateway startup.
- Exec approvals/channels: unify Discord and Telegram exec approval runtime handling, move approval buttons onto the shared interactive reply model, and fix Telegram approval buttons and typed `/approve` commands so configured approvers can resolve requests reliably again. (#57516) Thanks @scoootscooob.
## 2026.3.24-beta.2

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -3,9 +3,9 @@ import os from "node:os";
import path from "node:path";
import type { ButtonInteraction, ComponentData } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { clearSessionStoreCacheForTest } from "openclaw/plugin-sdk/config-runtime";
import type { DiscordExecApprovalConfig } from "openclaw/plugin-sdk/config-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { clearSessionStoreCacheForTest } from "../../../../src/config/sessions.js";
import type { DiscordExecApprovalConfig } from "../../../../src/config/types.discord.js";
const STORE_PATH = path.join(os.tmpdir(), "openclaw-exec-approvals-test.json");
@@ -46,9 +46,7 @@ const mockRestPatch = vi.hoisted(() => vi.fn());
const mockRestDelete = vi.hoisted(() => vi.fn());
const gatewayClientStarts = vi.hoisted(() => vi.fn());
const gatewayClientStops = vi.hoisted(() => vi.fn());
const gatewayClientRequests = vi.hoisted(() =>
vi.fn(async (_method?: string, _params?: unknown) => ({ ok: true })),
);
const gatewayClientRequests = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => ({ ok: true })));
const gatewayClientParams = vi.hoisted(() => [] as Array<Record<string, unknown>>);
const mockGatewayClientCtor = vi.hoisted(() => vi.fn());
const mockResolveGatewayConnectionAuth = vi.hoisted(() => vi.fn());
@@ -87,8 +85,8 @@ vi.mock("openclaw/plugin-sdk/gateway-runtime", async (importOriginal) => {
stop() {
gatewayClientStops();
}
async request(method: string, params?: unknown) {
return gatewayClientRequests(method, params);
async request(...args: unknown[]) {
return gatewayClientRequests(...args);
}
}
return {
@@ -128,56 +126,9 @@ vi.mock("openclaw/plugin-sdk/gateway-runtime", async (importOriginal) => {
};
});
vi.mock("../../../../src/gateway/operator-approvals-client.js", () => ({
createOperatorApprovalsGatewayClient: async (params: {
config?: unknown;
gatewayUrl?: string;
clientDisplayName?: string;
onEvent?: unknown;
onHelloOk?: unknown;
onConnectError?: unknown;
onClose?: unknown;
}) => {
mockCreateOperatorApprovalsGatewayClient(params);
const envUrl = process.env.OPENCLAW_GATEWAY_URL?.trim();
const gatewayUrl = params.gatewayUrl?.trim() || envUrl || "ws://127.0.0.1:18789";
const urlOverrideSource = params.gatewayUrl?.trim() ? "cli" : envUrl ? "env" : undefined;
const auth = await mockResolveGatewayConnectionAuth({
config: params.config,
env: process.env,
...(urlOverrideSource
? {
urlOverride: gatewayUrl,
urlOverrideSource,
}
: {}),
});
const clientParams = {
url: gatewayUrl,
token: auth?.token,
password: auth?.password,
clientName: "gateway-client",
clientDisplayName: params.clientDisplayName,
mode: "backend",
scopes: ["operator.approvals"],
onEvent: params.onEvent,
onHelloOk: params.onHelloOk,
onConnectError: params.onConnectError,
onClose: params.onClose,
};
gatewayClientParams.push(clientParams);
mockGatewayClientCtor(clientParams);
return {
start: gatewayClientStarts,
stop: gatewayClientStops,
request: gatewayClientRequests,
};
},
}));
vi.mock("../../../../src/gateway/client.js", () => ({
GatewayClient: class {
params: Record<string, unknown>;
vi.mock("../../../../src/gateway/operator-approvals-client.js", async () => {
class MockGatewayClient {
private params: Record<string, unknown>;
constructor(params: Record<string, unknown>) {
this.params = params;
gatewayClientParams.push(params);
@@ -189,31 +140,58 @@ vi.mock("../../../../src/gateway/client.js", () => ({
stop() {
gatewayClientStops();
}
async request() {
return gatewayClientRequests();
async request(...args: unknown[]) {
return gatewayClientRequests(...args);
}
},
}));
}
vi.mock("../../../../src/gateway/connection-auth.js", () => ({
resolveGatewayConnectionAuth: (params: {
config?: unknown;
env: NodeJS.ProcessEnv;
urlOverride?: string;
urlOverrideSource?: "cli" | "env";
}) => mockResolveGatewayConnectionAuth(params),
}));
vi.mock("../client.js", () => ({
createDiscordClient: () => ({
rest: {
post: mockRestPost,
patch: mockRestPatch,
delete: mockRestDelete,
return {
createOperatorApprovalsGatewayClient: async (params: {
config?: {
gateway?: {
auth?: {
token?: string;
password?: string;
};
};
};
gatewayUrl?: string;
clientDisplayName: string;
onEvent?: unknown;
onHelloOk?: () => void;
onConnectError?: (err: Error) => void;
onClose?: (code: number, reason: string) => void;
}) => {
mockCreateOperatorApprovalsGatewayClient(params);
const envUrl = process.env.OPENCLAW_GATEWAY_URL?.trim();
const gatewayUrl = params.gatewayUrl?.trim() || envUrl || "ws://127.0.0.1:18789";
const urlOverrideSource = params.gatewayUrl?.trim() ? "cli" : envUrl ? "env" : undefined;
const auth = await mockResolveGatewayConnectionAuth({
config: params.config,
env: process.env,
...(urlOverrideSource
? {
urlOverride: gatewayUrl,
urlOverrideSource,
}
: {}),
});
return new MockGatewayClient({
url: gatewayUrl,
token: auth?.token,
password: auth?.password,
clientName: "gateway-client",
clientDisplayName: params.clientDisplayName,
mode: "backend",
scopes: ["operator.approvals"],
onEvent: params.onEvent,
onHelloOk: params.onHelloOk,
onConnectError: params.onConnectError,
onClose: params.onClose,
});
},
request: (_fn: () => Promise<unknown>, _label: string) => _fn(),
}),
}));
};
});
vi.mock("openclaw/plugin-sdk/text-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/text-runtime")>();
@@ -242,66 +220,6 @@ type ExecApprovalRequest = import("./exec-approvals.js").ExecApprovalRequest;
type PluginApprovalRequest = import("./exec-approvals.js").PluginApprovalRequest;
type ExecApprovalButtonContext = import("./exec-approvals.js").ExecApprovalButtonContext;
function createTestingDeps() {
return {
createGatewayClient: async (params: {
config?: unknown;
gatewayUrl?: string;
clientDisplayName?: string;
onEvent?: unknown;
onHelloOk?: unknown;
onConnectError?: unknown;
onClose?: unknown;
}) => {
mockCreateOperatorApprovalsGatewayClient(params);
const envUrl = process.env.OPENCLAW_GATEWAY_URL?.trim();
const gatewayUrl = params.gatewayUrl?.trim() || envUrl || "ws://127.0.0.1:18789";
const urlOverrideSource = params.gatewayUrl?.trim() ? "cli" : envUrl ? "env" : undefined;
const auth = await mockResolveGatewayConnectionAuth({
config: params.config,
env: process.env,
...(urlOverrideSource
? {
urlOverride: gatewayUrl,
urlOverrideSource,
}
: {}),
});
const clientParams = {
url: gatewayUrl,
token: auth?.token,
password: auth?.password,
clientName: "gateway-client",
clientDisplayName: params.clientDisplayName,
mode: "backend",
scopes: ["operator.approvals"],
onEvent: params.onEvent,
onHelloOk: params.onHelloOk,
onConnectError: params.onConnectError,
onClose: params.onClose,
};
gatewayClientParams.push(clientParams);
mockGatewayClientCtor(clientParams);
return {
start: gatewayClientStarts,
stop: gatewayClientStops,
request: gatewayClientRequests,
} as unknown as InstanceType<
typeof import("../../../../src/gateway/client.js").GatewayClient
>;
},
createDiscordClient: () => ({
rest: {
post: mockRestPost,
patch: mockRestPatch,
delete: mockRestDelete,
},
request: (_fn: () => Promise<unknown>, _label: string) => _fn(),
token: "test-token",
}),
};
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function createHandler(config: DiscordExecApprovalConfig, accountId = "default") {
@@ -310,7 +228,6 @@ function createHandler(config: DiscordExecApprovalConfig, accountId = "default")
accountId,
config,
cfg: { session: { store: STORE_PATH } },
__testing: createTestingDeps(),
});
}
@@ -370,30 +287,6 @@ async function expectGatewayAuthStart(params: {
expect(mockGatewayClientCtor).toHaveBeenCalledWith(expect.objectContaining(expectedClientParams));
}
type ExecApprovalHandlerInternals = {
pending: Map<
string,
{ discordMessageId: string; discordChannelId: string; timeoutId: NodeJS.Timeout }
>;
requestCache: Map<string, unknown>;
handleApprovalRequested: (request: ExecApprovalRequest | PluginApprovalRequest) => Promise<void>;
handleApprovalTimeout: (approvalId: string, source?: "channel" | "dm") => Promise<void>;
};
function getHandlerInternals(
handler: DiscordExecApprovalHandlerInstance,
): ExecApprovalHandlerInternals {
return handler as unknown as ExecApprovalHandlerInternals;
}
function clearPendingTimeouts(handler: DiscordExecApprovalHandlerInstance) {
const internals = getHandlerInternals(handler);
for (const pending of internals.pending.values()) {
clearTimeout(pending.timeoutId);
}
internals.pending.clear();
}
function createRequest(
overrides: Partial<ExecApprovalRequest["request"]> = {},
): ExecApprovalRequest {
@@ -418,11 +311,10 @@ function createPluginRequest(
return {
id: "plugin:test-id",
request: {
title: "Sensitive plugin action",
description: "The plugin wants to run a sensitive tool action.",
severity: "warning",
toolName: "plugin.tool",
pluginId: "plugin-test",
title: "Plugin approval required",
description: "Allow plugin action",
pluginId: "test-plugin",
toolName: "test-tool",
agentId: "test-agent",
sessionKey: "agent:test-agent:discord:channel:999888777",
...overrides,
@@ -432,19 +324,6 @@ function createPluginRequest(
};
}
function createMockButtonInteraction(userId: string) {
const reply = vi.fn().mockResolvedValue(undefined);
const acknowledge = vi.fn().mockResolvedValue(undefined);
const followUp = vi.fn().mockResolvedValue(undefined);
const interaction = {
userId,
reply,
acknowledge,
followUp,
} as unknown as ButtonInteraction;
return { interaction, reply, acknowledge, followUp };
}
beforeEach(() => {
mockRestPost.mockReset();
mockRestPatch.mockReset();
@@ -458,6 +337,7 @@ beforeEach(() => {
});
beforeAll(async () => {
vi.resetModules();
({
buildExecApprovalCustomId,
extractDiscordChannelId,
@@ -726,104 +606,6 @@ describe("DiscordExecApprovalHandler.shouldHandle", () => {
});
});
describe("DiscordExecApprovalHandler plugin approvals", () => {
beforeEach(() => {
mockRestPost.mockClear().mockResolvedValue({ id: "mock-message", channel_id: "mock-channel" });
mockRestPatch.mockClear().mockResolvedValue({});
mockRestDelete.mockClear().mockResolvedValue({});
});
it("delivers plugin approval requests with interactive approval buttons", async () => {
const handler = createHandler({ enabled: true, approvers: ["123"] });
const internals = getHandlerInternals(handler);
mockSuccessfulDmDelivery({ throwOnUnexpectedRoute: true });
await internals.handleApprovalRequested(createPluginRequest());
const dmCall = mockRestPost.mock.calls.find(
(call) => call[0] === Routes.channelMessages("dm-1"),
) as [string, { body?: unknown }] | undefined;
expect(dmCall).toBeDefined();
expect(dmCall?.[1]?.body).toBeDefined();
const bodyJson = JSON.stringify(dmCall?.[1]?.body ?? {});
expect(bodyJson).toContain("Plugin Approval Required");
expect(bodyJson).toContain("plugin:test-id");
expect(bodyJson).toContain("execapproval:id=plugin%3Atest-id;action=allow-once");
clearPendingTimeouts(handler);
});
it("handles plugin approvals end-to-end via gateway event, button resolve, and card update", async () => {
const handler = createHandler({ enabled: true, approvers: ["123"] });
mockSuccessfulDmDelivery({
noteChannelId: "999888777",
expectedNoteText: "I sent approval DMs to the approvers for this account",
throwOnUnexpectedRoute: true,
});
await handler.start();
try {
const onEvent = gatewayClientParams[0]?.onEvent as
| ((evt: { event: string; payload: unknown }) => void)
| undefined;
expect(typeof onEvent).toBe("function");
const request = createPluginRequest();
onEvent?.({
event: "plugin.approval.requested",
payload: request,
});
await vi.waitFor(() => {
expect(mockRestPost).toHaveBeenCalledWith(
Routes.channelMessages("dm-1"),
expect.objectContaining({
body: expect.objectContaining({
components: expect.any(Array),
}),
}),
);
});
const button = new ExecApprovalButton({ handler });
const { interaction, acknowledge } = createMockButtonInteraction("123");
await button.run(interaction, { id: request.id, action: "allow-once" });
expect(acknowledge).toHaveBeenCalledTimes(1);
expect(gatewayClientRequests).toHaveBeenCalledWith("plugin.approval.resolve", {
id: request.id,
decision: "allow-once",
});
onEvent?.({
event: "plugin.approval.resolved",
payload: {
id: request.id,
decision: "allow-once",
resolvedBy: "discord:123",
ts: Date.now(),
request: request.request,
},
});
await vi.waitFor(() => {
expect(mockRestPatch).toHaveBeenCalledWith(
Routes.channelMessage("dm-1", "msg-1"),
expect.objectContaining({ body: expect.any(Object) }),
);
});
const patchCall = mockRestPatch.mock.calls.find(
(call) => call[0] === Routes.channelMessage("dm-1", "msg-1"),
) as [string, { body?: unknown }] | undefined;
const patchBody = JSON.stringify(patchCall?.[1]?.body ?? {});
expect(patchBody).toContain("Plugin Approval: Allowed (once)");
} finally {
clearPendingTimeouts(handler);
await handler.stop();
}
});
});
// ─── DiscordExecApprovalHandler.getApprovers ──────────────────────────────────
describe("DiscordExecApprovalHandler.getApprovers", () => {
@@ -853,40 +635,6 @@ describe("DiscordExecApprovalHandler.getApprovers", () => {
});
});
describe("DiscordExecApprovalHandler.resolveApproval", () => {
it("routes non-prefixed approval IDs to exec.approval.resolve", async () => {
const handler = createHandler({ enabled: true, approvers: ["123"] });
await handler.start();
try {
const ok = await handler.resolveApproval("exec-123", "allow-once");
expect(ok).toBe(true);
expect(gatewayClientRequests).toHaveBeenCalledWith("exec.approval.resolve", {
id: "exec-123",
decision: "allow-once",
});
} finally {
await handler.stop();
}
});
it("routes plugin-prefixed approval IDs to plugin.approval.resolve", async () => {
const handler = createHandler({ enabled: true, approvers: ["123"] });
await handler.start();
try {
const ok = await handler.resolveApproval("plugin:abc-123", "deny");
expect(ok).toBe(true);
expect(gatewayClientRequests).toHaveBeenCalledWith("plugin.approval.resolve", {
id: "plugin:abc-123",
decision: "deny",
});
} finally {
await handler.stop();
}
});
});
// ─── ExecApprovalButton authorization ─────────────────────────────────────────
describe("ExecApprovalButton", () => {
@@ -924,7 +672,7 @@ describe("ExecApprovalButton", () => {
await button.run(interaction, data);
expect(reply).toHaveBeenCalledWith({
content: "⛔ You are not authorized to approve requests.",
content: "⛔ You are not authorized to approve exec requests.",
ephemeral: true,
});
expect(acknowledge).not.toHaveBeenCalled();
@@ -1101,7 +849,6 @@ describe("DiscordExecApprovalHandler gateway auth", () => {
auth: { mode: "token", token: "shared-gateway-token" },
},
},
__testing: createTestingDeps(),
});
await handler.start();
@@ -1128,7 +875,6 @@ describe("DiscordExecApprovalHandler gateway auth", () => {
auth: { mode: "token" },
},
},
__testing: createTestingDeps(),
});
try {
@@ -1153,40 +899,6 @@ describe("DiscordExecApprovalHandler timeout cleanup", () => {
mockRestPatch.mockClear().mockResolvedValue({});
mockRestDelete.mockClear().mockResolvedValue({});
});
it("cleans up request cache for the exact approval id", async () => {
const handler = createHandler({ enabled: true, approvers: ["123"] });
const internals = getHandlerInternals(handler);
const requestA = { ...createRequest(), id: "abc" };
const requestB = { ...createRequest(), id: "abc2" };
internals.requestCache.set("abc", { kind: "exec", request: requestA });
internals.requestCache.set("abc2", { kind: "exec", request: requestB });
const timeoutIdA = setTimeout(() => {}, 0);
const timeoutIdB = setTimeout(() => {}, 0);
clearTimeout(timeoutIdA);
clearTimeout(timeoutIdB);
internals.pending.set("abc:dm", {
discordMessageId: "m1",
discordChannelId: "c1",
timeoutId: timeoutIdA,
});
internals.pending.set("abc2:dm", {
discordMessageId: "m2",
discordChannelId: "c2",
timeoutId: timeoutIdB,
});
await internals.handleApprovalTimeout("abc", "dm");
expect(internals.pending.has("abc:dm")).toBe(false);
expect(internals.requestCache.has("abc")).toBe(false);
expect(internals.requestCache.has("abc2")).toBe(true);
clearPendingTimeouts(handler);
});
});
// ─── Delivery routing ────────────────────────────────────────────────────────
@@ -1204,12 +916,11 @@ describe("DiscordExecApprovalHandler delivery routing", () => {
approvers: ["123"],
target: "channel",
});
const internals = getHandlerInternals(handler);
mockSuccessfulDmDelivery();
const request = createRequest({ sessionKey: "agent:main:discord:dm:123" });
await internals.handleApprovalRequested(request);
await handler.handleApprovalRequested(request);
expect(mockRestPost).toHaveBeenCalledTimes(2);
expect(mockRestPost).toHaveBeenCalledWith(Routes.userChannels(), {
@@ -1223,8 +934,6 @@ describe("DiscordExecApprovalHandler delivery routing", () => {
}),
}),
);
clearPendingTimeouts(handler);
});
it("posts an in-channel note when target is dm and the request came from a non-DM discord conversation", async () => {
@@ -1233,7 +942,6 @@ describe("DiscordExecApprovalHandler delivery routing", () => {
approvers: ["123"],
target: "dm",
});
const internals = getHandlerInternals(handler);
mockSuccessfulDmDelivery({
noteChannelId: "999888777",
@@ -1241,13 +949,15 @@ describe("DiscordExecApprovalHandler delivery routing", () => {
throwOnUnexpectedRoute: true,
});
await internals.handleApprovalRequested(createRequest());
await handler.handleApprovalRequested(createRequest());
expect(mockRestPost).toHaveBeenCalledWith(
Routes.channelMessages("999888777"),
expect.objectContaining({
body: expect.objectContaining({
content: expect.stringContaining("I sent approval DMs to the approvers for this account"),
content: expect.stringContaining(
"I sent approval DMs to the approvers for this account",
),
}),
}),
);
@@ -1257,8 +967,6 @@ describe("DiscordExecApprovalHandler delivery routing", () => {
body: expect.any(Object),
}),
);
clearPendingTimeouts(handler);
});
it("does not post an in-channel note when the request already came from a discord DM", async () => {
@@ -1267,11 +975,10 @@ describe("DiscordExecApprovalHandler delivery routing", () => {
approvers: ["123"],
target: "dm",
});
const internals = getHandlerInternals(handler);
mockSuccessfulDmDelivery({ throwOnUnexpectedRoute: true });
await internals.handleApprovalRequested(
await handler.handleApprovalRequested(
createRequest({ sessionKey: "agent:main:discord:dm:123" }),
);
@@ -1279,8 +986,55 @@ describe("DiscordExecApprovalHandler delivery routing", () => {
Routes.channelMessages("999888777"),
expect.anything(),
);
});
clearPendingTimeouts(handler);
it("delivers plugin approvals through the shared runtime flow", async () => {
const handler = createHandler({
enabled: true,
approvers: ["123"],
target: "dm",
});
mockSuccessfulDmDelivery({ throwOnUnexpectedRoute: true });
await handler.handleApprovalRequested(createPluginRequest());
expect(mockRestPost).toHaveBeenCalledWith(
Routes.channelMessages("dm-1"),
expect.objectContaining({
body: expect.objectContaining({
components: expect.arrayContaining([
expect.objectContaining({
components: expect.arrayContaining([
expect.objectContaining({
content: expect.stringContaining("Plugin Approval Required"),
}),
expect.objectContaining({
content: expect.stringContaining("Plugin approval required"),
}),
]),
}),
]),
}),
}),
);
});
});
describe("DiscordExecApprovalHandler resolve routing", () => {
it("routes plugin approval ids through plugin.approval.resolve", async () => {
const handler = createHandler({
enabled: true,
approvers: ["123"],
});
await handler.start();
await expect(handler.resolveApproval("plugin:test-id", "allow-once")).resolves.toBe(true);
expect(gatewayClientRequests).toHaveBeenCalledWith("plugin.approval.resolve", {
id: "plugin:test-id",
decision: "allow-once",
});
});
});
@@ -1296,7 +1050,6 @@ describe("DiscordExecApprovalHandler gateway auth resolution", () => {
gatewayUrl: "wss://override.example/ws",
config: { enabled: true, approvers: ["123"] },
cfg: { session: { store: STORE_PATH } },
__testing: createTestingDeps(),
});
await expectGatewayAuthStart({
@@ -1319,7 +1072,6 @@ describe("DiscordExecApprovalHandler gateway auth resolution", () => {
accountId: "default",
config: { enabled: true, approvers: ["123"] },
cfg: { session: { store: STORE_PATH } },
__testing: createTestingDeps(),
});
await expectGatewayAuthStart({

View File

@@ -10,20 +10,24 @@ import {
type TopLevelComponents,
} from "@buape/carbon";
import { ButtonStyle, Routes } from "discord-api-types/v10";
import {
getExecApprovalApproverDmNoticeText,
resolveExecApprovalCommandDisplay,
type ExecApprovalDecision,
type ExecApprovalRequest,
type ExecApprovalResolved,
type PluginApprovalRequest,
type PluginApprovalResolved,
} from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
import type { DiscordExecApprovalConfig } from "openclaw/plugin-sdk/config-runtime";
import type { EventFrame } from "openclaw/plugin-sdk/gateway-runtime";
import * as gatewayRuntime from "openclaw/plugin-sdk/gateway-runtime";
import {
createExecApprovalChannelRuntime,
type ExecApprovalChannelRuntime,
} from "openclaw/plugin-sdk/infra-runtime";
import { buildExecApprovalActionDescriptors } from "openclaw/plugin-sdk/infra-runtime";
import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime";
import { getExecApprovalApproverDmNoticeText } from "openclaw/plugin-sdk/infra-runtime";
import type {
ExecApprovalActionDescriptor,
ExecApprovalDecision,
ExecApprovalRequest,
ExecApprovalResolved,
PluginApprovalRequest,
PluginApprovalResolved,
} from "openclaw/plugin-sdk/infra-runtime";
import {
normalizeAccountId,
normalizeMessageChannel,
@@ -32,7 +36,7 @@ import {
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { compileSafeRegex, testRegexWithBoundedInput } from "openclaw/plugin-sdk/security-runtime";
import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime";
import * as sendShared from "../send.shared.js";
import { createDiscordClient, stripUndefinedFields } from "../send.shared.js";
import { DiscordUiContainer } from "../ui.js";
const EXEC_APPROVAL_KEY = "execapproval";
@@ -43,6 +47,10 @@ export type {
PluginApprovalResolved,
};
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved;
type ApprovalKind = "exec" | "plugin";
/** Extract Discord channel ID from a session key like "agent:main:discord:channel:123456789" */
export function extractDiscordChannelId(sessionKey?: string | null): string | null {
if (!sessionKey) {
@@ -62,14 +70,15 @@ function buildDiscordApprovalDmRedirectNotice(): { content: string } {
type PendingApproval = {
discordMessageId: string;
discordChannelId: string;
timeoutId: NodeJS.Timeout;
};
type ApprovalKind = "exec" | "plugin";
function resolveApprovalKindFromId(approvalId: string): ApprovalKind {
return approvalId.startsWith("plugin:") ? "plugin" : "exec";
}
type CachedApprovalRequest =
| { kind: "exec"; request: ExecApprovalRequest }
| { kind: "plugin"; request: PluginApprovalRequest };
function isPluginApprovalRequest(request: ApprovalRequest): request is PluginApprovalRequest {
return resolveApprovalKindFromId(request.id) === "plugin";
}
function encodeCustomIdValue(value: string): string {
return encodeURIComponent(value);
@@ -115,16 +124,6 @@ export function parseExecApprovalData(
};
}
function resolveApprovalKindFromId(approvalId: string): ApprovalKind {
return approvalId.startsWith("plugin:") ? "plugin" : "exec";
}
function isPluginApprovalRequest(
request: ExecApprovalRequest | PluginApprovalRequest,
): request is PluginApprovalRequest {
return resolveApprovalKindFromId(request.id) === "plugin";
}
type ExecApprovalContainerParams = {
cfg: OpenClawConfig;
accountId: string;
@@ -177,49 +176,36 @@ class ExecApprovalActionButton extends Button {
label: string;
style: ButtonStyle;
constructor(params: {
approvalId: string;
action: ExecApprovalDecision;
label: string;
style: ButtonStyle;
}) {
constructor(params: { approvalId: string; descriptor: ExecApprovalActionDescriptor }) {
super();
this.customId = buildExecApprovalCustomId(params.approvalId, params.action);
this.label = params.label;
this.style = params.style;
this.customId = buildExecApprovalCustomId(params.approvalId, params.descriptor.decision);
this.label = params.descriptor.label;
this.style =
params.descriptor.style === "success"
? ButtonStyle.Success
: params.descriptor.style === "primary"
? ButtonStyle.Primary
: params.descriptor.style === "danger"
? ButtonStyle.Danger
: ButtonStyle.Secondary;
}
}
class ExecApprovalActionRow extends Row<Button> {
constructor(approvalId: string) {
super([
new ExecApprovalActionButton({
approvalId,
action: "allow-once",
label: "Allow once",
style: ButtonStyle.Success,
}),
new ExecApprovalActionButton({
approvalId,
action: "allow-always",
label: "Always allow",
style: ButtonStyle.Primary,
}),
new ExecApprovalActionButton({
approvalId,
action: "deny",
label: "Deny",
style: ButtonStyle.Danger,
}),
...buildExecApprovalActionDescriptors({ approvalCommandId: approvalId }).map(
(descriptor) => new ExecApprovalActionButton({ approvalId, descriptor }),
),
]);
}
}
function resolveAccountIdFromSessionKey(params: {
function resolveExecApprovalAccountId(params: {
cfg: OpenClawConfig;
sessionKey?: string | null;
request: ExecApprovalRequest;
}): string | null {
const sessionKey = params.sessionKey?.trim();
const sessionKey = params.request.request.sessionKey?.trim();
if (!sessionKey) {
return null;
}
@@ -239,23 +225,21 @@ function resolveAccountIdFromSessionKey(params: {
}
}
function resolveExecApprovalAccountId(params: {
cfg: OpenClawConfig;
request: ExecApprovalRequest;
}): string | null {
return resolveAccountIdFromSessionKey({
cfg: params.cfg,
sessionKey: params.request.request.sessionKey,
});
}
function resolvePluginApprovalAccountId(params: {
cfg: OpenClawConfig;
request: PluginApprovalRequest;
}): string | null {
const fromSession = resolveAccountIdFromSessionKey({
const fromSession = resolveExecApprovalAccountId({
cfg: params.cfg,
sessionKey: params.request.request.sessionKey,
request: {
id: params.request.id,
request: {
command: params.request.request.title,
sessionKey: params.request.request.sessionKey ?? undefined,
},
createdAtMs: params.request.createdAtMs,
expiresAtMs: params.request.expiresAtMs,
},
});
if (fromSession) {
return fromSession;
@@ -265,22 +249,18 @@ function resolvePluginApprovalAccountId(params: {
function resolveApprovalAccountId(params: {
cfg: OpenClawConfig;
request: ExecApprovalRequest | PluginApprovalRequest;
request: ApprovalRequest;
}): string | null {
return isPluginApprovalRequest(params.request)
? resolvePluginApprovalAccountId({ cfg: params.cfg, request: params.request })
: resolveExecApprovalAccountId({ cfg: params.cfg, request: params.request });
}
function resolveApprovalAgentId(
request: ExecApprovalRequest | PluginApprovalRequest,
): string | null {
function resolveApprovalAgentId(request: ApprovalRequest): string | null {
return request.request.agentId?.trim() || null;
}
function resolveApprovalSessionKey(
request: ExecApprovalRequest | PluginApprovalRequest,
): string | null {
function resolveApprovalSessionKey(request: ApprovalRequest): string | null {
return request.request.sessionKey?.trim() || null;
}
@@ -526,31 +506,34 @@ export type DiscordExecApprovalHandlerOpts = {
cfg: OpenClawConfig;
runtime?: RuntimeEnv;
onResolve?: (id: string, decision: ExecApprovalDecision) => Promise<void>;
__testing?: {
createGatewayClient?: typeof gatewayRuntime.createOperatorApprovalsGatewayClient;
createDiscordClient?: (...args: Parameters<typeof sendShared.createDiscordClient>) => {
rest: {
post: (...args: unknown[]) => Promise<unknown>;
patch: (...args: unknown[]) => Promise<unknown>;
delete: (...args: unknown[]) => Promise<unknown>;
};
request: (fn: () => Promise<unknown>, label: string) => Promise<unknown>;
};
};
};
export class DiscordExecApprovalHandler {
private gatewayClient: gatewayRuntime.GatewayClient | null = null;
private pending = new Map<string, PendingApproval>();
private requestCache = new Map<string, CachedApprovalRequest>();
private readonly runtime: ExecApprovalChannelRuntime<ApprovalRequest, ApprovalResolved>;
private opts: DiscordExecApprovalHandlerOpts;
private started = false;
constructor(opts: DiscordExecApprovalHandlerOpts) {
this.opts = opts;
this.runtime = createExecApprovalChannelRuntime<PendingApproval, ApprovalRequest, ApprovalResolved>({
label: "discord/exec-approvals",
clientDisplayName: "Discord Exec Approvals",
cfg: this.opts.cfg,
gatewayUrl: this.opts.gatewayUrl,
eventKinds: ["exec", "plugin"],
isConfigured: () =>
Boolean(this.opts.config.enabled && (this.opts.config.approvers?.length ?? 0) > 0),
shouldHandle: (request) => this.shouldHandle(request),
deliverRequested: async (request) => await this.deliverRequested(request),
finalizeResolved: async ({ request, resolved, entries }) => {
await this.finalizeResolved(request, resolved, entries);
},
finalizeExpired: async ({ request, entries }) => {
await this.finalizeExpired(request, entries);
},
});
}
shouldHandle(request: ExecApprovalRequest | PluginApprovalRequest): boolean {
shouldHandle(request: ApprovalRequest): boolean {
const config = this.opts.config;
if (!config.enabled) {
return false;
@@ -603,127 +586,45 @@ export class DiscordExecApprovalHandler {
}
async start(): Promise<void> {
if (this.started) {
return;
}
this.started = true;
const config = this.opts.config;
if (!config.enabled) {
logDebug("discord exec approvals: disabled");
return;
}
if (!config.approvers || config.approvers.length === 0) {
logDebug("discord exec approvals: no approvers configured");
return;
}
logDebug("discord exec approvals: starting handler");
this.gatewayClient = await (
this.opts.__testing?.createGatewayClient ??
gatewayRuntime.createOperatorApprovalsGatewayClient
)({
config: this.opts.cfg,
gatewayUrl: this.opts.gatewayUrl,
clientDisplayName: "Discord Exec Approvals",
onEvent: (evt) => this.handleGatewayEvent(evt),
onHelloOk: () => {
logDebug("discord exec approvals: connected to gateway");
},
onConnectError: (err) => {
logError(`discord exec approvals: connect error: ${err.message}`);
},
onClose: (code, reason) => {
logDebug(`discord exec approvals: gateway closed: ${code} ${reason}`);
},
});
this.gatewayClient.start();
await this.runtime.start();
}
async stop(): Promise<void> {
if (!this.started) {
return;
}
this.started = false;
// Clear all pending timeouts
for (const pending of this.pending.values()) {
clearTimeout(pending.timeoutId);
}
this.pending.clear();
this.requestCache.clear();
this.gatewayClient?.stop();
this.gatewayClient = null;
logDebug("discord exec approvals: stopped");
await this.runtime.stop();
}
private handleGatewayEvent(evt: EventFrame): void {
if (evt.event === "exec.approval.requested") {
const request = evt.payload as ExecApprovalRequest;
void this.handleApprovalRequested(request);
} else if (evt.event === "plugin.approval.requested") {
const request = evt.payload as PluginApprovalRequest;
void this.handleApprovalRequested(request);
} else if (evt.event === "exec.approval.resolved") {
const resolved = evt.payload as ExecApprovalResolved;
void this.handleApprovalResolved(resolved);
} else if (evt.event === "plugin.approval.resolved") {
const resolved = evt.payload as PluginApprovalResolved;
void this.handleApprovalResolved(resolved);
}
}
private async handleApprovalRequested(
request: ExecApprovalRequest | PluginApprovalRequest,
): Promise<void> {
if (!this.shouldHandle(request)) {
return;
}
const pluginRequest: PluginApprovalRequest | null = isPluginApprovalRequest(request)
? request
: null;
logDebug(
`discord exec approvals: received ${pluginRequest ? "plugin" : "exec"} request ${request.id}`,
private async deliverRequested(request: ApprovalRequest): Promise<PendingApproval[]> {
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
let container: ExecApprovalContainer;
if (pluginRequest) {
this.requestCache.set(request.id, { kind: "plugin", request: pluginRequest });
container = createPluginApprovalRequestContainer({
request: pluginRequest,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
actionRow: new ExecApprovalActionRow(request.id),
});
} else {
const execRequest = request as ExecApprovalRequest;
this.requestCache.set(request.id, { kind: "exec", request: execRequest });
container = createExecApprovalRequestContainer({
request: execRequest,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
actionRow: new ExecApprovalActionRow(request.id),
});
}
const { rest, request: discordRequest } = (
this.opts.__testing?.createDiscordClient ?? sendShared.createDiscordClient
)({ token: this.opts.token, accountId: this.opts.accountId }, this.opts.cfg);
const actionRow = new ExecApprovalActionRow(request.id);
const container = isPluginApprovalRequest(request)
? createPluginApprovalRequestContainer({
request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
actionRow,
})
: createExecApprovalRequestContainer({
request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
actionRow,
});
const payload = buildExecApprovalPayload(container);
const body = sendShared.stripUndefinedFields(serializePayload(payload));
const body = stripUndefinedFields(serializePayload(payload));
const target = this.opts.config.target ?? "dm";
const sendToDm = target === "dm" || target === "both";
const sendToChannel = target === "channel" || target === "both";
let fallbackToDm = false;
const sessionKey = resolveApprovalSessionKey(request);
const pendingEntries: PendingApproval[] = [];
const originatingChannelId =
sessionKey && target === "dm" ? extractDiscordChannelId(sessionKey) : null;
target === "dm"
? extractDiscordChannelId(resolveApprovalSessionKey(request))
: null;
if (target === "dm" && originatingChannelId) {
try {
@@ -741,6 +642,7 @@ export class DiscordExecApprovalHandler {
// Send to originating channel if configured
if (sendToChannel) {
const sessionKey = resolveApprovalSessionKey(request);
const channelId = extractDiscordChannelId(sessionKey);
if (channelId) {
try {
@@ -753,15 +655,9 @@ export class DiscordExecApprovalHandler {
)) as { id: string; channel_id: string };
if (message?.id) {
const timeoutMs = Math.max(0, request.expiresAtMs - Date.now());
const timeoutId = setTimeout(() => {
void this.handleApprovalTimeout(request.id, "channel");
}, timeoutMs);
this.pending.set(`${request.id}:channel`, {
pendingEntries.push({
discordMessageId: message.id,
discordChannelId: channelId,
timeoutId,
});
logDebug(`discord exec approvals: sent approval ${request.id} to channel ${channelId}`);
@@ -816,22 +712,9 @@ export class DiscordExecApprovalHandler {
continue;
}
// Clear any existing pending DM entry to avoid timeout leaks
const existingDm = this.pending.get(`${request.id}:dm`);
if (existingDm) {
clearTimeout(existingDm.timeoutId);
}
// Set up timeout
const timeoutMs = Math.max(0, request.expiresAtMs - Date.now());
const timeoutId = setTimeout(() => {
void this.handleApprovalTimeout(request.id, "dm");
}, timeoutMs);
this.pending.set(`${request.id}:dm`, {
pendingEntries.push({
discordMessageId: message.id,
discordChannelId: dmChannel.id,
timeoutId,
});
logDebug(`discord exec approvals: sent approval ${request.id} to user ${userId}`);
@@ -840,98 +723,65 @@ export class DiscordExecApprovalHandler {
}
}
}
return pendingEntries;
}
private async handleApprovalResolved(
resolved: ExecApprovalResolved | PluginApprovalResolved,
async handleApprovalRequested(request: ApprovalRequest): Promise<void> {
await this.runtime.handleRequested(request);
}
async handleApprovalResolved(resolved: ApprovalResolved): Promise<void> {
await this.runtime.handleResolved(resolved);
}
async handleApprovalTimeout(approvalId: string, _source?: "channel" | "dm"): Promise<void> {
await this.runtime.handleExpired(approvalId);
}
private async finalizeResolved(
request: ApprovalRequest,
resolved: ApprovalResolved,
entries: PendingApproval[],
): Promise<void> {
// Clean up all pending entries for this approval (channel + dm)
const cached = this.requestCache.get(resolved.id);
this.requestCache.delete(resolved.id);
if (!cached) {
return;
}
logDebug(
`discord exec approvals: resolved ${cached.kind} ${resolved.id} with ${resolved.decision}`,
);
const container =
cached.kind === "plugin"
? createPluginResolvedContainer({
request: cached.request,
decision: resolved.decision,
resolvedBy: resolved.resolvedBy,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
})
: createExecResolvedContainer({
request: cached.request,
decision: resolved.decision,
resolvedBy: resolved.resolvedBy,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
});
for (const suffix of [":channel", ":dm", ""]) {
const key = `${resolved.id}${suffix}`;
const pending = this.pending.get(key);
if (!pending) {
continue;
}
clearTimeout(pending.timeoutId);
this.pending.delete(key);
const container = isPluginApprovalRequest(request)
? createPluginResolvedContainer({
request,
decision: resolved.decision,
resolvedBy: resolved.resolvedBy,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
})
: createExecResolvedContainer({
request,
decision: resolved.decision,
resolvedBy: resolved.resolvedBy,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
});
for (const pending of entries) {
await this.finalizeMessage(pending.discordChannelId, pending.discordMessageId, container);
}
}
private async handleApprovalTimeout(
approvalId: string,
source?: "channel" | "dm",
private async finalizeExpired(
request: ApprovalRequest,
entries: PendingApproval[],
): Promise<void> {
const key = source ? `${approvalId}:${source}` : approvalId;
const pending = this.pending.get(key);
if (!pending) {
return;
const container = isPluginApprovalRequest(request)
? createPluginExpiredContainer({
request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
})
: createExecExpiredContainer({
request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
});
for (const pending of entries) {
await this.finalizeMessage(pending.discordChannelId, pending.discordMessageId, container);
}
this.pending.delete(key);
const cached = this.requestCache.get(approvalId);
// Only clean up requestCache if no other pending entries exist for this approval
const hasOtherPending =
this.pending.has(`${approvalId}:channel`) ||
this.pending.has(`${approvalId}:dm`) ||
this.pending.has(approvalId);
if (!hasOtherPending) {
this.requestCache.delete(approvalId);
}
if (!cached) {
return;
}
logDebug(
`discord exec approvals: timeout for ${cached.kind} ${approvalId} (${source ?? "default"})`,
);
const container =
cached.kind === "plugin"
? createPluginExpiredContainer({
request: cached.request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
})
: createExecExpiredContainer({
request: cached.request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
});
await this.finalizeMessage(pending.discordChannelId, pending.discordMessageId, container);
}
private async finalizeMessage(
@@ -945,9 +795,10 @@ export class DiscordExecApprovalHandler {
}
try {
const { rest, request: discordRequest } = (
this.opts.__testing?.createDiscordClient ?? sendShared.createDiscordClient
)({ token: this.opts.token, accountId: this.opts.accountId }, this.opts.cfg);
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
await discordRequest(
() => rest.delete(Routes.channelMessage(channelId, messageId)) as Promise<void>,
@@ -965,15 +816,16 @@ export class DiscordExecApprovalHandler {
container: DiscordUiContainer,
): Promise<void> {
try {
const { rest, request: discordRequest } = (
this.opts.__testing?.createDiscordClient ?? sendShared.createDiscordClient
)({ token: this.opts.token, accountId: this.opts.accountId }, this.opts.cfg);
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
const payload = buildExecApprovalPayload(container);
await discordRequest(
() =>
rest.patch(Routes.channelMessage(channelId, messageId), {
body: sendShared.stripUndefinedFields(serializePayload(payload)),
body: stripUndefinedFields(serializePayload(payload)),
}),
"update-approval",
);
@@ -983,11 +835,6 @@ export class DiscordExecApprovalHandler {
}
async resolveApproval(approvalId: string, decision: ExecApprovalDecision): Promise<boolean> {
if (!this.gatewayClient) {
logError("discord exec approvals: gateway client not connected");
return false;
}
const method =
resolveApprovalKindFromId(approvalId) === "plugin"
? "plugin.approval.resolve"
@@ -995,7 +842,7 @@ export class DiscordExecApprovalHandler {
logDebug(`discord exec approvals: resolving ${approvalId} with ${decision} via ${method}`);
try {
await this.gatewayClient.request(method, {
await this.runtime.request(method, {
id: approvalId,
decision,
});
@@ -1048,7 +895,7 @@ export class ExecApprovalButton extends Button {
if (!approvers.some((id) => String(id) === userId)) {
try {
await interaction.reply({
content: "⛔ You are not authorized to approve requests.",
content: "⛔ You are not authorized to approve exec requests.",
ephemeral: true,
});
} catch {

View File

@@ -11,6 +11,7 @@ import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/re
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
import { deliverReplies, emitInternalMessageSentHook } from "./bot/delivery.js";
import { createTelegramDraftStream } from "./draft-stream.js";
import { resolveTelegramExecApproval } from "./exec-approval-resolver.js";
import { editMessageTelegram } from "./send.js";
import { wasSentByBot } from "./sent-message-cache.js";
@@ -26,6 +27,7 @@ export type TelegramBotDeps = {
buildModelsProviderData: typeof buildModelsProviderData;
listSkillCommandsForAgents: typeof listSkillCommandsForAgents;
wasSentByBot: typeof wasSentByBot;
resolveExecApproval?: typeof resolveTelegramExecApproval;
createTelegramDraftStream?: typeof createTelegramDraftStream;
deliverReplies?: typeof deliverReplies;
emitInternalMessageSentHook?: typeof emitInternalMessageSentHook;
@@ -66,6 +68,9 @@ export const defaultTelegramBotDeps: TelegramBotDeps = {
get wasSentByBot() {
return wasSentByBot;
},
get resolveExecApproval() {
return resolveTelegramExecApproval;
},
get createTelegramDraftStream() {
return createTelegramDraftStream;
},

View File

@@ -1,7 +1,7 @@
import type { Message, ReactionTypeEmoji } from "@grammyjs/types";
import { resolveAgentDir, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime";
import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-writes";
import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-helpers";
import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-inbound";
import {
createInboundDebouncer,
@@ -31,6 +31,7 @@ import {
parsePluginBindingApprovalCustomId,
resolvePluginConversationBindingApproval,
} from "openclaw/plugin-sdk/conversation-runtime";
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/infra-runtime";
import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
@@ -43,7 +44,6 @@ import {
} from "./bot-access.js";
import { defaultTelegramBotDeps } from "./bot-deps.js";
import {
APPROVE_CALLBACK_DATA_RE,
hasInboundMedia,
hasReplyTargetMedia,
isMediaSizeLimitError,
@@ -70,16 +70,13 @@ import {
resolveTelegramGroupAllowFromContext,
withResolvedTelegramForumFlag,
} from "./bot/helpers.js";
import type {
TelegramContext,
TelegramGetChat,
TelegramSyntheticContextSource,
} from "./bot/types.js";
import type { TelegramContext, TelegramGetChat } from "./bot/types.js";
import {
resolveTelegramConversationBaseSessionKey,
resolveTelegramConversationRoute,
} from "./conversation-route.js";
import { enforceTelegramDmAccess } from "./dm-access.js";
import { resolveTelegramExecApproval } from "./exec-approval-resolver.js";
import {
isTelegramExecApprovalApprover,
isTelegramExecApprovalAuthorizedSender,
@@ -102,18 +99,6 @@ import {
} from "./model-buttons.js";
import { buildInlineKeyboard } from "./send.js";
function parseApprovalCallbackId(data: string): string | null {
const trimmed = data.trim();
if (!trimmed.startsWith("/approve")) {
return null;
}
const tokens = trimmed.split(/\s+/).filter(Boolean);
if (tokens.length < 3) {
return null;
}
return tokens[1] ?? null;
}
export const registerTelegramHandlers = ({
cfg,
accountId,
@@ -173,7 +158,20 @@ export const registerTelegramHandlers = ({
botUsername?: string;
};
const resolveTelegramDebounceLane = (msg: Message): TelegramDebounceLane => {
return msg.forward_origin ? "forward" : "default";
const forwardMeta = msg as {
forward_origin?: unknown;
forward_from?: unknown;
forward_from_chat?: unknown;
forward_sender_name?: unknown;
forward_date?: unknown;
};
return (forwardMeta.forward_origin ??
forwardMeta.forward_from ??
forwardMeta.forward_from_chat ??
forwardMeta.forward_sender_name ??
forwardMeta.forward_date)
? "forward"
: "default";
};
const buildSyntheticTextMessage = (params: {
base: Message;
@@ -190,20 +188,15 @@ export const registerTelegramHandlers = ({
...(params.date != null ? { date: params.date } : {}),
});
const buildSyntheticContext = (
ctx: TelegramSyntheticContextSource,
ctx: Pick<TelegramContext, "me"> & { getFile?: unknown },
message: Message,
): TelegramContext => {
const getFile =
typeof ctx.getFile === "function" ? ctx.getFile.bind(ctx as object) : async () => ({});
typeof ctx.getFile === "function"
? (ctx.getFile as TelegramContext["getFile"]).bind(ctx as object)
: async () => ({});
return { message, me: ctx.me, getFile };
};
const isSelfAuthoredTelegramMessage = (
ctx: Pick<TelegramContext, "me">,
message: Message,
): boolean => {
const botId = ctx.me?.id;
return typeof botId === "number" && message.from?.id === botId;
};
const inboundDebouncer = createInboundDebouncer<TelegramDebounceEntry>({
debounceMs,
resolveDebounceMs: (entry) =>
@@ -633,7 +626,10 @@ export const registerTelegramHandlers = ({
type TelegramEventAuthorizationMode = "reaction" | "callback-scope" | "callback-allowlist";
type TelegramEventAuthorizationResult = { allowed: true } | { allowed: false; reason: string };
type TelegramEventAuthorizationContext = TelegramGroupAllowContext & { dmPolicy: DmPolicy };
const getChat: TelegramGetChat = bot.api.getChat.bind(bot.api);
const getChat =
typeof (bot.api as { getChat?: unknown }).getChat === "function"
? ((bot.api as { getChat: TelegramGetChat }).getChat.bind(bot.api) as TelegramGetChat)
: undefined;
const TELEGRAM_EVENT_AUTH_RULES: Record<
TelegramEventAuthorizationMode,
@@ -1099,10 +1095,10 @@ export const registerTelegramHandlers = ({
if (shouldSkipUpdate(ctx)) {
return;
}
const answerCallbackQuery = () =>
typeof ctx.answerCallbackQuery === "function"
? ctx.answerCallbackQuery()
: bot.api.answerCallbackQuery(callback.id);
const answerCallbackQuery =
typeof (ctx as { answerCallbackQuery?: unknown }).answerCallbackQuery === "function"
? () => ctx.answerCallbackQuery()
: () => bot.api.answerCallbackQuery(callback.id);
// Answer immediately to prevent Telegram from retrying while we process
await withTelegramApiErrorLogging({
operation: "answerCallbackQuery",
@@ -1118,26 +1114,41 @@ export const registerTelegramHandlers = ({
const editCallbackMessage = async (
text: string,
params?: Parameters<typeof bot.api.editMessageText>[3],
) =>
typeof ctx.editMessageText === "function"
? ctx.editMessageText(text, params)
: bot.api.editMessageText(
callbackMessage.chat.id,
callbackMessage.message_id,
text,
params,
);
) => {
const editTextFn = (ctx as { editMessageText?: unknown }).editMessageText;
if (typeof editTextFn === "function") {
return await ctx.editMessageText(text, params);
}
return await bot.api.editMessageText(
callbackMessage.chat.id,
callbackMessage.message_id,
text,
params,
);
};
const clearCallbackButtons = async () => {
const emptyKeyboard = { inline_keyboard: [] };
const replyMarkup = { reply_markup: emptyKeyboard };
if (typeof ctx.editMessageReplyMarkup === "function") {
const editReplyMarkupFn = (ctx as { editMessageReplyMarkup?: unknown })
.editMessageReplyMarkup;
if (typeof editReplyMarkupFn === "function") {
return await ctx.editMessageReplyMarkup(replyMarkup);
}
return await bot.api.editMessageReplyMarkup(
callbackMessage.chat.id,
callbackMessage.message_id,
replyMarkup,
);
const apiEditReplyMarkupFn = (bot.api as { editMessageReplyMarkup?: unknown })
.editMessageReplyMarkup;
if (typeof apiEditReplyMarkupFn === "function") {
return await bot.api.editMessageReplyMarkup(
callbackMessage.chat.id,
callbackMessage.message_id,
replyMarkup,
);
}
// Fallback path for older clients that do not expose editMessageReplyMarkup.
const messageText = callbackMessage.text ?? callbackMessage.caption;
if (typeof messageText !== "string" || messageText.trim().length === 0) {
return undefined;
}
return await editCallbackMessage(messageText, replyMarkup);
};
const editCallbackButtons = async (
buttons: Array<
@@ -1146,7 +1157,9 @@ export const registerTelegramHandlers = ({
) => {
const keyboard = buildInlineKeyboard(buttons) ?? { inline_keyboard: [] };
const replyMarkup = { reply_markup: keyboard };
if (typeof ctx.editMessageReplyMarkup === "function") {
const editReplyMarkupFn = (ctx as { editMessageReplyMarkup?: unknown })
.editMessageReplyMarkup;
if (typeof editReplyMarkupFn === "function") {
return await ctx.editMessageReplyMarkup(replyMarkup);
}
return await bot.api.editMessageReplyMarkup(
@@ -1156,22 +1169,28 @@ export const registerTelegramHandlers = ({
);
};
const deleteCallbackMessage = async () => {
return typeof ctx.deleteMessage === "function"
? ctx.deleteMessage()
: bot.api.deleteMessage(callbackMessage.chat.id, callbackMessage.message_id);
const deleteFn = (ctx as { deleteMessage?: unknown }).deleteMessage;
if (typeof deleteFn === "function") {
return await ctx.deleteMessage();
}
return await bot.api.deleteMessage(callbackMessage.chat.id, callbackMessage.message_id);
};
const replyToCallbackChat = async (
text: string,
params?: Parameters<typeof bot.api.sendMessage>[2],
) =>
typeof ctx.reply === "function"
? ctx.reply(text, params)
: bot.api.sendMessage(callbackMessage.chat.id, text, params);
) => {
const replyFn = (ctx as { reply?: unknown }).reply;
if (typeof replyFn === "function") {
return await ctx.reply(text, params);
}
return await bot.api.sendMessage(callbackMessage.chat.id, text, params);
};
const chatId = callbackMessage.chat.id;
const isGroup =
callbackMessage.chat.type === "group" || callbackMessage.chat.type === "supergroup";
const isApprovalCallback = APPROVE_CALLBACK_DATA_RE.test(data);
const approvalCallback = parseExecApprovalCommandText(data);
const isApprovalCallback = approvalCallback !== null;
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
cfg,
accountId,
@@ -1219,7 +1238,6 @@ export const registerTelegramHandlers = ({
}
const senderId = callback.from?.id ? String(callback.from.id) : "";
const senderUsername = callback.from?.username ?? "";
// DM callbacks must enforce the same sender authorization gate as normal DM commands.
const authorizationMode: TelegramEventAuthorizationMode =
!isGroup || (!execApprovalButtonsEnabled && inlineButtonsScope === "allowlist")
? "callback-allowlist"
@@ -1303,22 +1321,45 @@ export const registerTelegramHandlers = ({
}
const runtimeCfg = telegramDeps.loadConfig();
if (isApprovalCallback) {
const approvalId = parseApprovalCallbackId(data);
const isPluginApprovalCallback = approvalId?.startsWith("plugin:") ?? false;
if (!isTelegramExecApprovalAuthorizedSender({ cfg: runtimeCfg, accountId, senderId })) {
if (approvalCallback) {
const isPluginApproval = approvalCallback.approvalId.startsWith("plugin:");
const pluginApprovalAuthorizedSender = isTelegramExecApprovalApprover({
cfg: runtimeCfg,
accountId,
senderId,
});
const execApprovalAuthorizedSender = isTelegramExecApprovalAuthorizedSender({
cfg: runtimeCfg,
accountId,
senderId,
});
const authorizedApprovalSender = isPluginApproval
? pluginApprovalAuthorizedSender
: execApprovalAuthorizedSender || pluginApprovalAuthorizedSender;
if (
!authorizedApprovalSender
) {
logVerbose(
`Blocked telegram exec approval callback from ${senderId || "unknown"} (not an approver)`,
`Blocked telegram exec approval callback from ${senderId || "unknown"} (not authorized)`,
);
return;
}
if (
isPluginApprovalCallback &&
!isTelegramExecApprovalApprover({ cfg: runtimeCfg, accountId, senderId })
) {
try {
// Resolve approval callbacks directly so Telegram approvers are not forced through
// the generic chat-command authorization path.
await (telegramDeps.resolveExecApproval ?? resolveTelegramExecApproval)({
cfg: runtimeCfg,
approvalId: approvalCallback.approvalId,
decision: approvalCallback.decision,
senderId,
allowPluginFallback: pluginApprovalAuthorizedSender,
});
} catch (resolveErr) {
const errStr = String(resolveErr);
logVerbose(
`Blocked telegram plugin approval callback from ${senderId || "unknown"} (not an explicit approver)`,
`telegram: failed to resolve approval callback ${approvalCallback.approvalId}: ${errStr}`,
);
await replyToCallbackChat(`❌ Failed to submit approval: ${errStr}`);
return;
}
try {
@@ -1326,12 +1367,14 @@ export const registerTelegramHandlers = ({
} catch (editErr) {
const errStr = String(editErr);
if (
!errStr.includes("message is not modified") &&
!errStr.includes("there is no text in the message to edit")
errStr.includes("message is not modified") ||
errStr.includes("there is no text in the message to edit")
) {
logVerbose(`telegram: failed to clear approval callback buttons: ${errStr}`);
return;
}
logVerbose(`telegram: failed to clear approval callback buttons: ${errStr}`);
}
return;
}
const paginationMatch = data.match(/^commands_page_(\d+|noop)(?::(.+))?$/);
@@ -1389,7 +1432,7 @@ export const registerTelegramHandlers = ({
runtimeCfg,
sessionState.agentId,
);
const { byProvider, providers, modelNames } = modelData;
const { byProvider, providers } = modelData;
const editMessageWithButtons = async (
text: string,
@@ -1464,7 +1507,6 @@ export const registerTelegramHandlers = ({
currentPage: safePage,
totalPages,
pageSize,
modelNames,
});
const text = formatModelsAvailableHeader({
provider,
@@ -1551,14 +1593,14 @@ export const registerTelegramHandlers = ({
return;
}
const nativeCommandText = parseTelegramNativeCommandCallbackData(data);
const nativeCallbackCommand = parseTelegramNativeCommandCallbackData(data);
const syntheticMessage = buildSyntheticTextMessage({
base: withResolvedTelegramForumFlag(callbackMessage, isForum),
from: callback.from,
text: nativeCommandText ?? data,
text: nativeCallbackCommand ?? data,
});
await processMessage(buildSyntheticContext(ctx, syntheticMessage), [], storeAllowFrom, {
commandSource: nativeCommandText ? "native" : undefined,
...(nativeCallbackCommand ? { commandSource: "native" as const } : {}),
forceWasMentioned: true,
messageIdOverride: callback.id,
});
@@ -1724,9 +1766,6 @@ export const registerTelegramHandlers = ({
if (!msg) {
return;
}
if (isSelfAuthoredTelegramMessage(ctx, msg)) {
return;
}
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
const isForum = await resolveTelegramForumFlag({
chatId: msg.chat.id,
@@ -1736,6 +1775,9 @@ export const registerTelegramHandlers = ({
getChat,
});
const normalizedMsg = withResolvedTelegramForumFlag(msg, isForum);
if (!isGroup && normalizedMsg.from?.id != null && normalizedMsg.from.id === ctx.me?.id) {
return;
}
await handleInboundMessageLike({
ctxForDedupe: ctx,
ctx: buildSyntheticContext(ctx, normalizedMsg),

View File

@@ -273,6 +273,10 @@ const systemEventsHoisted = vi.hoisted(() => ({
}));
export const enqueueSystemEventSpy: MockFn<TelegramBotDeps["enqueueSystemEvent"]> =
systemEventsHoisted.enqueueSystemEventSpy;
const execApprovalHoisted = vi.hoisted(() => ({
resolveExecApprovalSpy: vi.fn(async () => undefined),
}));
export const resolveExecApprovalSpy = execApprovalHoisted.resolveExecApprovalSpy;
vi.doMock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-runtime")>();
@@ -415,6 +419,9 @@ export const telegramBotDepsForTest: TelegramBotDeps = {
listSkillCommandsForAgents:
listSkillCommandsForAgents as TelegramBotDeps["listSkillCommandsForAgents"],
wasSentByBot: wasSentByBot as TelegramBotDeps["wasSentByBot"],
resolveExecApproval: resolveExecApprovalSpy as NonNullable<
TelegramBotDeps["resolveExecApproval"]
>,
};
vi.doMock("./bot.runtime.js", () => telegramBotRuntimeForTest);
@@ -508,6 +515,8 @@ beforeEach(() => {
await opts?.onReplyStart?.();
return undefined;
});
resolveExecApprovalSpy.mockReset();
resolveExecApprovalSpy.mockResolvedValue(undefined);
dispatchReplyWithBufferedBlockDispatcher.mockReset();
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async (params: DispatchReplyHarnessParams) =>

View File

@@ -21,6 +21,7 @@ const {
listSkillCommandsForAgents,
onSpy,
replySpy,
resolveExecApprovalSpy,
sendMessageSpy,
setMyCommandsSpy,
telegramBotDepsForTest,
@@ -215,6 +216,7 @@ describe("createTelegramBot", () => {
it("blocks callback_query when inline buttons are allowlist-only and sender not authorized", async () => {
onSpy.mockClear();
replySpy.mockClear();
sendMessageSpy.mockClear();
createTelegramBot({
token: "tok",
@@ -366,6 +368,7 @@ describe("createTelegramBot", () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
@@ -417,6 +420,24 @@ describe("createTelegramBot", () => {
expect(chatId).toBe(1234);
expect(messageId).toBe(21);
expect(replyMarkup).toEqual({ reply_markup: { inline_keyboard: [] } });
expect(resolveExecApprovalSpy).toHaveBeenCalledWith({
cfg: expect.objectContaining({
channels: expect.objectContaining({
telegram: expect.objectContaining({
execApprovals: expect.objectContaining({
enabled: true,
approvers: ["9"],
target: "dm",
}),
}),
}),
}),
approvalId: "138e9b8c",
decision: "allow-once",
allowPluginFallback: true,
senderId: "9",
});
expect(replySpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-style");
});
@@ -466,10 +487,73 @@ describe("createTelegramBot", () => {
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-capability-free");
});
it("resolves plugin approval callbacks through the shared approval resolver", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = getOnHandler("callback_query") as (
ctx: Record<string, unknown>,
) => Promise<void>;
expect(callbackHandler).toBeDefined();
await callbackHandler({
callbackQuery: {
id: "cbq-plugin-approve",
data: "/approve plugin:138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 24,
text: "Plugin approval required.",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(resolveExecApprovalSpy).toHaveBeenCalledWith({
cfg: expect.objectContaining({
channels: expect.objectContaining({
telegram: expect.objectContaining({
execApprovals: expect.objectContaining({
enabled: true,
approvers: ["9"],
target: "dm",
}),
}),
}),
}),
approvalId: "plugin:138e9b8c",
decision: "allow-once",
allowPluginFallback: true,
senderId: "9",
});
expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1);
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-plugin-approve");
});
it("blocks approval callbacks from telegram users who are not exec approvers", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
@@ -508,9 +592,140 @@ describe("createTelegramBot", () => {
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).not.toHaveBeenCalled();
expect(resolveExecApprovalSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-blocked");
});
it("allows exec approval callbacks from target-only Telegram recipients", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
loadConfig.mockReturnValue({
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "telegram", to: "9" }],
},
},
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
expect(callbackHandler).toBeDefined();
await callbackHandler({
callbackQuery: {
id: "cbq-approve-target",
data: "/approve 138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 23,
text: "Approval required.",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(resolveExecApprovalSpy).toHaveBeenCalledWith({
cfg: expect.objectContaining({
approvals: expect.objectContaining({
exec: expect.objectContaining({
enabled: true,
mode: "targets",
}),
}),
}),
approvalId: "138e9b8c",
decision: "allow-once",
allowPluginFallback: false,
senderId: "9",
});
expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1);
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-target");
});
it("does not allow target-only recipients to use legacy plugin fallback ids", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
replySpy.mockClear();
resolveExecApprovalSpy.mockRejectedValueOnce(new Error("unknown or expired approval id"));
loadConfig.mockReturnValue({
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "telegram", to: "9" }],
},
},
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
expect(callbackHandler).toBeDefined();
await callbackHandler({
callbackQuery: {
id: "cbq-legacy-plugin-fallback-blocked",
data: "/approve 138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 25,
text: "Legacy plugin approval required.",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(resolveExecApprovalSpy).toHaveBeenCalledWith({
cfg: expect.objectContaining({
approvals: expect.objectContaining({
exec: expect.objectContaining({
enabled: true,
mode: "targets",
}),
}),
}),
approvalId: "138e9b8c",
decision: "allow-once",
allowPluginFallback: false,
senderId: "9",
});
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
expect(replySpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledWith(
1234,
"❌ Failed to submit approval: Error: unknown or expired approval id",
undefined,
);
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-legacy-plugin-fallback-blocked");
});
it("keeps plugin approval callback buttons for target-only recipients", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { buildTelegramInteractiveButtons, resolveTelegramInlineButtons } from "./button-types.js";
describe("buildTelegramInteractiveButtons", () => {
it("maps shared buttons and selects into Telegram inline rows", () => {
expect(
buildTelegramInteractiveButtons({
blocks: [
{
type: "buttons",
buttons: [
{ label: "Approve", value: "approve", style: "success" },
{ label: "Reject", value: "reject", style: "danger" },
{ label: "Later", value: "later" },
{ label: "Archive", value: "archive" },
],
},
{
type: "select",
options: [{ label: "Alpha", value: "alpha" }],
},
],
}),
).toEqual([
[
{ text: "Approve", callback_data: "approve", style: "success" },
{ text: "Reject", callback_data: "reject", style: "danger" },
{ text: "Later", callback_data: "later", style: undefined },
],
[{ text: "Archive", callback_data: "archive", style: undefined }],
[{ text: "Alpha", callback_data: "alpha", style: undefined }],
]);
});
it("drops buttons whose callback payload exceeds Telegram limits", () => {
expect(
buildTelegramInteractiveButtons({
blocks: [
{
type: "buttons",
buttons: [
{ label: "Keep", value: "ok" },
{ label: "Drop", value: `x${"y".repeat(80)}` },
],
},
],
}),
).toEqual([[{ text: "Keep", callback_data: "ok", style: undefined }]]);
});
});
describe("resolveTelegramInlineButtons", () => {
it("prefers explicit buttons over shared interactive blocks", () => {
const explicit = [[{ text: "Keep", callback_data: "keep" }]] as const;
expect(
resolveTelegramInlineButtons({
buttons: explicit,
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Override", value: "override" }],
},
],
},
}),
).toBe(explicit);
});
it("derives buttons from raw interactive payloads", () => {
expect(
resolveTelegramInlineButtons({
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Retry", value: "retry", style: "primary" }],
},
],
},
}),
).toEqual([[{ text: "Retry", callback_data: "retry", style: "primary" }]]);
});
});

View File

@@ -49,6 +49,7 @@ import {
listTelegramDirectoryGroupsFromConfig,
listTelegramDirectoryPeersFromConfig,
} from "./directory-config.js";
import { buildTelegramExecApprovalPendingPayload } from "./exec-approval-forwarding.js";
import {
getTelegramExecApprovalApprovers,
isTelegramExecApprovalApprover,
@@ -595,6 +596,12 @@ export const telegramPlugin = createChatChannelPlugin({
auth: telegramNativeApprovalAdapter.auth,
approvals: {
delivery: telegramNativeApprovalAdapter.delivery,
render: {
exec: {
buildPendingPayload: ({ request, nowMs }) =>
buildTelegramExecApprovalPendingPayload({ request, nowMs }),
},
},
},
directory: createChannelDirectoryAdapter({
listPeers: async (params) => listTelegramDirectoryPeersFromConfig(params),

View File

@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const gatewayRuntimeHoisted = vi.hoisted(() => ({
requestSpy: vi.fn(),
startSpy: vi.fn(),
stopSpy: vi.fn(),
stopAndWaitSpy: vi.fn(async () => undefined),
createClientSpy: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/gateway-runtime", () => ({
createOperatorApprovalsGatewayClient: gatewayRuntimeHoisted.createClientSpy,
}));
describe("resolveTelegramExecApproval", () => {
beforeEach(() => {
gatewayRuntimeHoisted.requestSpy.mockReset();
gatewayRuntimeHoisted.startSpy.mockReset();
gatewayRuntimeHoisted.stopSpy.mockReset();
gatewayRuntimeHoisted.stopAndWaitSpy.mockReset().mockResolvedValue(undefined);
gatewayRuntimeHoisted.createClientSpy.mockReset().mockImplementation((opts) => ({
start: () => {
gatewayRuntimeHoisted.startSpy();
opts.onHelloOk?.();
},
request: gatewayRuntimeHoisted.requestSpy,
stop: gatewayRuntimeHoisted.stopSpy,
stopAndWait: gatewayRuntimeHoisted.stopAndWaitSpy,
}));
});
it("routes plugin approval ids through plugin.approval.resolve", async () => {
const { resolveTelegramExecApproval } = await import("./exec-approval-resolver.js");
await resolveTelegramExecApproval({
cfg: {} as never,
approvalId: "plugin:abc123",
decision: "allow-once",
senderId: "9",
});
expect(gatewayRuntimeHoisted.requestSpy).toHaveBeenCalledWith("plugin.approval.resolve", {
id: "plugin:abc123",
decision: "allow-once",
});
});
it("falls back to plugin.approval.resolve when exec approval ids are unknown", async () => {
gatewayRuntimeHoisted.requestSpy
.mockRejectedValueOnce(new Error("unknown or expired approval id"))
.mockResolvedValueOnce(undefined);
const { resolveTelegramExecApproval } = await import("./exec-approval-resolver.js");
await resolveTelegramExecApproval({
cfg: {} as never,
approvalId: "legacy-plugin-123",
decision: "allow-always",
senderId: "9",
});
expect(gatewayRuntimeHoisted.requestSpy).toHaveBeenNthCalledWith(1, "exec.approval.resolve", {
id: "legacy-plugin-123",
decision: "allow-always",
});
expect(gatewayRuntimeHoisted.requestSpy).toHaveBeenNthCalledWith(
2,
"plugin.approval.resolve",
{
id: "legacy-plugin-123",
decision: "allow-always",
},
);
});
it("falls back to plugin.approval.resolve for structured approval-not-found errors", async () => {
const err = new Error("invalid request") as Error & {
gatewayCode?: string;
details?: { reason?: string };
};
err.gatewayCode = "INVALID_REQUEST";
err.details = { reason: "APPROVAL_NOT_FOUND" };
gatewayRuntimeHoisted.requestSpy.mockRejectedValueOnce(err).mockResolvedValueOnce(undefined);
const { resolveTelegramExecApproval } = await import("./exec-approval-resolver.js");
await resolveTelegramExecApproval({
cfg: {} as never,
approvalId: "legacy-plugin-123",
decision: "deny",
senderId: "9",
});
expect(gatewayRuntimeHoisted.requestSpy).toHaveBeenNthCalledWith(1, "exec.approval.resolve", {
id: "legacy-plugin-123",
decision: "deny",
});
expect(gatewayRuntimeHoisted.requestSpy).toHaveBeenNthCalledWith(
2,
"plugin.approval.resolve",
{
id: "legacy-plugin-123",
decision: "deny",
},
);
});
});

View File

@@ -0,0 +1,103 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { createOperatorApprovalsGatewayClient } from "openclaw/plugin-sdk/gateway-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/infra-runtime";
export type ResolveTelegramExecApprovalParams = {
cfg: OpenClawConfig;
approvalId: string;
decision: ExecApprovalReplyDecision;
senderId?: string | null;
allowPluginFallback?: boolean;
gatewayUrl?: string;
};
export async function resolveTelegramExecApproval(
params: ResolveTelegramExecApprovalParams,
): Promise<void> {
const isApprovalNotFoundError = (err: unknown): boolean => {
if (!(err instanceof Error)) {
return false;
}
const gatewayCode = (err as { gatewayCode?: unknown }).gatewayCode;
if (gatewayCode === "APPROVAL_NOT_FOUND") {
return true;
}
const details = (err as { details?: unknown }).details;
if (
gatewayCode === "INVALID_REQUEST" &&
details &&
typeof details === "object" &&
!Array.isArray(details) &&
(details as { reason?: unknown }).reason === "APPROVAL_NOT_FOUND"
) {
return true;
}
return /unknown or expired approval id/i.test(err.message);
};
let readySettled = false;
let resolveReady!: () => void;
let rejectReady!: (err: unknown) => void;
const ready = new Promise<void>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
const markReady = () => {
if (readySettled) {
return;
}
readySettled = true;
resolveReady();
};
const failReady = (err: unknown) => {
if (readySettled) {
return;
}
readySettled = true;
rejectReady(err);
};
const gatewayClient = await createOperatorApprovalsGatewayClient({
config: params.cfg,
gatewayUrl: params.gatewayUrl,
clientDisplayName: `Telegram approval (${params.senderId?.trim() || "unknown"})`,
onHelloOk: () => {
markReady();
},
onConnectError: (err) => {
failReady(err);
},
onClose: (code, reason) => {
// Once onHelloOk resolves `ready`, in-flight request failures must come from
// gatewayClient.request() itself; failReady only covers the pre-ready phase.
failReady(new Error(`gateway closed (${code}): ${reason}`));
},
});
try {
gatewayClient.start();
await ready;
const requestApproval = async (method: "exec.approval.resolve" | "plugin.approval.resolve") => {
await gatewayClient.request(method, {
id: params.approvalId,
decision: params.decision,
});
};
if (params.approvalId.startsWith("plugin:")) {
await requestApproval("plugin.approval.resolve");
} else {
try {
await requestApproval("exec.approval.resolve");
} catch (err) {
if (!params.allowPluginFallback || !isApprovalNotFoundError(err)) {
throw err;
}
await requestApproval("plugin.approval.resolve");
}
}
} finally {
await gatewayClient.stopAndWait().catch(() => {
gatewayClient.stop();
});
}
}

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { TelegramExecApprovalHandler } from "./exec-approvals-handler.js";
const baseRequest = {
@@ -75,16 +75,17 @@ describe("TelegramExecApprovalHandler", () => {
{
text: "Allow Once",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once",
style: "success",
},
{
text: "Allow Always",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 always",
style: "primary",
},
],
[
{
text: "Deny",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 deny",
style: "danger",
},
],
],

View File

@@ -1,20 +1,21 @@
import {
buildExecApprovalPendingReplyPayload,
resolveExecApprovalCommandDisplay,
resolveExecApprovalSessionTarget,
type ExecApprovalPendingReplyParams,
type ExecApprovalRequest,
type ExecApprovalResolved,
} from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { GatewayClient } from "openclaw/plugin-sdk/gateway-runtime";
import { createOperatorApprovalsGatewayClient } from "openclaw/plugin-sdk/gateway-runtime";
import type { EventFrame } from "openclaw/plugin-sdk/gateway-runtime";
import {
createExecApprovalChannelRuntime,
type ExecApprovalChannelRuntime,
} from "openclaw/plugin-sdk/infra-runtime";
import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime";
import {
buildExecApprovalInteractiveReply,
buildExecApprovalPendingReplyPayload,
type ExecApprovalPendingReplyParams,
} from "openclaw/plugin-sdk/infra-runtime";
import { resolveExecApprovalSessionTarget } from "openclaw/plugin-sdk/infra-runtime";
import type { ExecApprovalRequest, ExecApprovalResolved } from "openclaw/plugin-sdk/infra-runtime";
import { normalizeAccountId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { compileSafeRegex, testRegexWithBoundedInput } from "openclaw/plugin-sdk/security-runtime";
import { buildTelegramExecApprovalButtons } from "./approval-buttons.js";
import { resolveTelegramInlineButtons } from "./button-types.js";
import {
getTelegramExecApprovalApprovers,
resolveTelegramExecApprovalConfig,
@@ -29,11 +30,6 @@ type PendingMessage = {
messageId: string;
};
type PendingApproval = {
timeoutId: NodeJS.Timeout;
messages: PendingMessage[];
};
type TelegramApprovalTarget = {
to: string;
threadId?: number;
@@ -185,9 +181,7 @@ function dedupeTargets(targets: TelegramApprovalTarget[]): TelegramApprovalTarge
}
export class TelegramExecApprovalHandler {
private gatewayClient: GatewayClient | null = null;
private pending = new Map<string, PendingApproval>();
private started = false;
private readonly runtime: ExecApprovalChannelRuntime;
private readonly nowMs: () => number;
private readonly sendTyping: typeof sendTypingTelegram;
private readonly sendMessage: typeof sendMessageTelegram;
@@ -201,6 +195,28 @@ export class TelegramExecApprovalHandler {
this.sendTyping = deps.sendTyping ?? sendTypingTelegram;
this.sendMessage = deps.sendMessage ?? sendMessageTelegram;
this.editReplyMarkup = deps.editReplyMarkup ?? editMessageReplyMarkupTelegram;
this.runtime = createExecApprovalChannelRuntime<PendingMessage>({
label: "telegram/exec-approvals",
clientDisplayName: `Telegram Exec Approvals (${this.opts.accountId})`,
cfg: this.opts.cfg,
gatewayUrl: this.opts.gatewayUrl,
nowMs: this.nowMs,
isConfigured: () =>
isHandlerConfigured({ cfg: this.opts.cfg, accountId: this.opts.accountId }),
shouldHandle: (request) =>
matchesFilters({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
request,
}),
deliverRequested: async (request) => await this.deliverRequested(request),
finalizeResolved: async ({ resolved, entries }) => {
await this.finalizeResolved(resolved, entries);
},
finalizeExpired: async ({ entries }) => {
await this.clearPending(entries);
},
});
}
shouldHandle(request: ExecApprovalRequest): boolean {
@@ -212,45 +228,18 @@ export class TelegramExecApprovalHandler {
}
async start(): Promise<void> {
if (this.started) {
return;
}
this.started = true;
if (!isHandlerConfigured({ cfg: this.opts.cfg, accountId: this.opts.accountId })) {
return;
}
this.gatewayClient = await createOperatorApprovalsGatewayClient({
config: this.opts.cfg,
gatewayUrl: this.opts.gatewayUrl,
clientDisplayName: `Telegram Exec Approvals (${this.opts.accountId})`,
onEvent: (evt) => this.handleGatewayEvent(evt),
onConnectError: (err) => {
log.error(`telegram exec approvals: connect error: ${err.message}`);
},
});
this.gatewayClient.start();
await this.runtime.start();
}
async stop(): Promise<void> {
if (!this.started) {
return;
}
this.started = false;
for (const pending of this.pending.values()) {
clearTimeout(pending.timeoutId);
}
this.pending.clear();
this.gatewayClient?.stop();
this.gatewayClient = null;
await this.runtime.stop();
}
async handleRequested(request: ExecApprovalRequest): Promise<void> {
if (!this.shouldHandle(request)) {
return;
}
await this.runtime.handleRequested(request);
}
private async deliverRequested(request: ExecApprovalRequest): Promise<PendingMessage[]> {
const targetMode = resolveTelegramExecApprovalTarget({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
@@ -280,7 +269,7 @@ export class TelegramExecApprovalHandler {
const resolvedTargets = dedupeTargets(targets);
if (resolvedTargets.length === 0) {
return;
return [];
}
const payloadParams: ExecApprovalPendingReplyParams = {
@@ -294,8 +283,15 @@ export class TelegramExecApprovalHandler {
expiresAtMs: request.expiresAtMs,
nowMs: this.nowMs(),
};
const payload = buildExecApprovalPendingReplyPayload(payloadParams);
const buttons = buildTelegramExecApprovalButtons(request.id);
const payload = {
...buildExecApprovalPendingReplyPayload(payloadParams),
interactive: buildExecApprovalInteractiveReply({
approvalCommandId: request.id,
}),
};
const buttons = resolveTelegramInlineButtons({
interactive: payload.interactive,
});
const sentMessages: PendingMessage[] = [];
for (const target of resolvedTargets) {
@@ -322,33 +318,23 @@ export class TelegramExecApprovalHandler {
log.error(`telegram exec approvals: failed to send request ${request.id}: ${String(err)}`);
}
}
if (sentMessages.length === 0) {
return;
}
const timeoutMs = Math.max(0, request.expiresAtMs - this.nowMs());
const timeoutId = setTimeout(() => {
void this.handleResolved({ id: request.id, decision: "deny", ts: Date.now() });
}, timeoutMs);
timeoutId.unref?.();
this.pending.set(request.id, {
timeoutId,
messages: sentMessages,
});
return sentMessages;
}
async handleResolved(resolved: ExecApprovalResolved): Promise<void> {
const pending = this.pending.get(resolved.id);
if (!pending) {
return;
}
clearTimeout(pending.timeoutId);
this.pending.delete(resolved.id);
await this.runtime.handleResolved(resolved);
}
private async finalizeResolved(
_resolved: ExecApprovalResolved,
messages: PendingMessage[],
): Promise<void> {
await this.clearPending(messages);
}
private async clearPending(messages: PendingMessage[]): Promise<void> {
await Promise.allSettled(
pending.messages.map(async (message) => {
messages.map(async (message) => {
await this.editReplyMarkup(message.chatId, message.messageId, [], {
cfg: this.opts.cfg,
token: this.opts.token,
@@ -357,14 +343,4 @@ export class TelegramExecApprovalHandler {
}),
);
}
private handleGatewayEvent(evt: EventFrame): void {
if (evt.event === "exec.approval.requested") {
void this.handleRequested(evt.payload as ExecApprovalRequest);
return;
}
if (evt.event === "exec.approval.resolved") {
void this.handleResolved(evt.payload as ExecApprovalResolved);
}
}
}

View File

@@ -1,3 +1,7 @@
import {
isTelegramExecApprovalAuthorizedSender,
isTelegramExecApprovalClientEnabled,
} from "../../../extensions/telegram/api.js";
import { callGateway } from "../../gateway/call.js";
import { ErrorCodes } from "../../gateway/protocol/index.js";
import { logVerbose } from "../../globals.js";
@@ -70,6 +74,17 @@ function buildResolvedByLabel(params: Parameters<CommandHandler>[0]): string {
return `${channel}:${sender}`;
}
function isAuthorizedTelegramExecSender(params: Parameters<CommandHandler>[0]): boolean {
if (params.command.channel !== "telegram") {
return false;
}
return isTelegramExecApprovalAuthorizedSender({
cfg: params.cfg,
accountId: params.ctx.AccountId,
senderId: params.command.senderId,
});
}
function readErrorCode(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value : null;
}
@@ -112,29 +127,58 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
if (!parsed) {
return null;
}
if (!params.command.isAuthorizedSender) {
if (!parsed.ok) {
return { shouldContinue: false, reply: { text: parsed.error } };
}
const isPluginId = parsed.id.startsWith("plugin:");
const telegramExecAuthorizedSender = isAuthorizedTelegramExecSender(params);
const execApprovalAuthorization = resolveApprovalCommandAuthorization({
cfg: params.cfg,
channel: params.command.channel,
accountId: params.ctx.AccountId,
senderId: params.command.senderId,
kind: "exec",
});
const pluginApprovalAuthorization = resolveApprovalCommandAuthorization({
cfg: params.cfg,
channel: params.command.channel,
accountId: params.ctx.AccountId,
senderId: params.command.senderId,
kind: "plugin",
});
const hasExplicitApprovalAuthorization =
(execApprovalAuthorization.explicit && execApprovalAuthorization.authorized) ||
(pluginApprovalAuthorization.explicit && pluginApprovalAuthorization.authorized);
if (
!params.command.isAuthorizedSender &&
!hasExplicitApprovalAuthorization
) {
logVerbose(
`Ignoring /approve from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
if (!parsed.ok) {
return { shouldContinue: false, reply: { text: parsed.error } };
if (
params.command.channel === "telegram" &&
!isPluginId &&
!telegramExecAuthorizedSender &&
!isTelegramExecApprovalClientEnabled({ cfg: params.cfg, accountId: params.ctx.AccountId })
) {
return {
shouldContinue: false,
reply: { text: "❌ Telegram exec approvals are not enabled for this bot account." },
};
}
const isPluginId = parsed.id.startsWith("plugin:");
const approvalAuthorization = resolveApprovalCommandAuthorization({
cfg: params.cfg,
channel: params.command.channel,
accountId: params.ctx.AccountId,
senderId: params.command.senderId,
kind: isPluginId ? "plugin" : "exec",
});
if (!approvalAuthorization.authorized) {
if (isPluginId && !pluginApprovalAuthorization.authorized) {
return {
shouldContinue: false,
reply: {
text: approvalAuthorization.reason ?? "❌ You are not authorized to approve this request.",
text:
pluginApprovalAuthorization.reason ??
"❌ You are not authorized to approve this request.",
},
};
}
@@ -160,7 +204,7 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
};
// Plugin approval IDs are kind-prefixed (`plugin:<uuid>`); route directly when detected.
// Unprefixed IDs try exec first, then fall back to plugin for backward compat.
// Unprefixed IDs try the authorized path first, then fall back for backward compat.
if (isPluginId) {
try {
await callApprovalMethod("plugin.approval.resolve");
@@ -170,19 +214,12 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
reply: { text: `❌ Failed to submit approval: ${String(err)}` },
};
}
} else {
} else if (execApprovalAuthorization.authorized) {
try {
await callApprovalMethod("exec.approval.resolve");
} catch (err) {
if (isApprovalNotFoundError(err)) {
const pluginFallbackAuthorization = resolveApprovalCommandAuthorization({
cfg: params.cfg,
channel: params.command.channel,
accountId: params.ctx.AccountId,
senderId: params.command.senderId,
kind: "plugin",
});
if (!pluginFallbackAuthorization.authorized) {
if (!pluginApprovalAuthorization.authorized) {
return {
shouldContinue: false,
reply: { text: `❌ Failed to submit approval: ${String(err)}` },
@@ -203,6 +240,28 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
};
}
}
} else if (pluginApprovalAuthorization.authorized) {
try {
await callApprovalMethod("plugin.approval.resolve");
} catch (err) {
if (isApprovalNotFoundError(err)) {
return {
shouldContinue: false,
reply: { text: `❌ Failed to submit approval: ${String(err)}` },
};
}
return {
shouldContinue: false,
reply: { text: `❌ Failed to submit approval: ${String(err)}` },
};
}
} else {
return {
shouldContinue: false,
reply: {
text: execApprovalAuthorization.reason ?? "❌ You are not authorized to approve this request.",
},
};
}
return {

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,7 @@ describe("resolveApprovalCommandAuthorization", () => {
senderId: "U123",
kind: "exec",
}),
).toEqual({ authorized: true });
).toEqual({ authorized: true, explicit: false });
});
it("delegates to the channel approval override when present", () => {
@@ -47,7 +47,7 @@ describe("resolveApprovalCommandAuthorization", () => {
senderId: "123",
kind: "exec",
}),
).toEqual({ authorized: true });
).toEqual({ authorized: true, explicit: true });
expect(
resolveApprovalCommandAuthorization({
@@ -57,6 +57,6 @@ describe("resolveApprovalCommandAuthorization", () => {
senderId: "123",
kind: "plugin",
}),
).toEqual({ authorized: false, reason: "plugin denied" });
).toEqual({ authorized: false, reason: "plugin denied", explicit: true });
});
});

View File

@@ -8,18 +8,20 @@ export function resolveApprovalCommandAuthorization(params: {
accountId?: string | null;
senderId?: string | null;
kind: "exec" | "plugin";
}): { authorized: boolean; reason?: string } {
}): { authorized: boolean; reason?: string; explicit: boolean } {
const channel = normalizeMessageChannel(params.channel);
if (!channel) {
return { authorized: true };
return { authorized: true, explicit: false };
}
return (
getChannelPlugin(channel)?.auth?.authorizeActorAction?.({
cfg: params.cfg,
accountId: params.accountId,
senderId: params.senderId,
action: "approve",
approvalKind: params.kind,
}) ?? { authorized: true }
);
const result = getChannelPlugin(channel)?.auth?.authorizeActorAction?.({
cfg: params.cfg,
accountId: params.accountId,
senderId: params.senderId,
action: "approve",
approvalKind: params.kind,
});
if (!result) {
return { authorized: true, explicit: false };
}
return { ...result, explicit: true };
}

View File

@@ -0,0 +1,439 @@
import type { GatewayClient } from "../gateway/client.js";
import type { PluginApprovalRequest, PluginApprovalResolved } from "./plugin-approvals.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockGatewayClientStarts = vi.hoisted(() => vi.fn());
const mockGatewayClientStops = vi.hoisted(() => vi.fn());
const mockGatewayClientRequests = vi.hoisted(() => vi.fn(async () => ({ ok: true })));
const mockCreateOperatorApprovalsGatewayClient = vi.hoisted(() => vi.fn());
const loggerMocks = vi.hoisted(() => ({
debug: vi.fn(),
error: vi.fn(),
}));
vi.mock("../gateway/operator-approvals-client.js", () => ({
createOperatorApprovalsGatewayClient: mockCreateOperatorApprovalsGatewayClient,
}));
vi.mock("../logging/subsystem.js", () => ({
createSubsystemLogger: () => loggerMocks,
}));
let createExecApprovalChannelRuntime: typeof import("./exec-approval-channel-runtime.js").createExecApprovalChannelRuntime;
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
beforeEach(() => {
mockGatewayClientStarts.mockReset();
mockGatewayClientStops.mockReset();
mockGatewayClientRequests.mockReset();
mockGatewayClientRequests.mockResolvedValue({ ok: true });
loggerMocks.debug.mockReset();
loggerMocks.error.mockReset();
mockCreateOperatorApprovalsGatewayClient.mockReset().mockImplementation(async () => ({
start: mockGatewayClientStarts,
stop: mockGatewayClientStops,
request: mockGatewayClientRequests,
}));
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
beforeEach(async () => {
vi.resetModules();
({ createExecApprovalChannelRuntime } = await import("./exec-approval-channel-runtime.js"));
});
describe("createExecApprovalChannelRuntime", () => {
it("does not connect when the adapter is not configured", async () => {
const runtime = createExecApprovalChannelRuntime({
label: "test/exec-approvals",
clientDisplayName: "Test Exec Approvals",
cfg: {} as never,
isConfigured: () => false,
shouldHandle: () => true,
deliverRequested: async () => [],
finalizeResolved: async () => undefined,
});
await runtime.start();
expect(mockCreateOperatorApprovalsGatewayClient).not.toHaveBeenCalled();
});
it("tracks pending requests and only expires the matching approval id", async () => {
vi.useFakeTimers();
const finalizedExpired = vi.fn(async () => undefined);
const finalizedResolved = vi.fn(async () => undefined);
const runtime = createExecApprovalChannelRuntime({
label: "test/exec-approvals",
clientDisplayName: "Test Exec Approvals",
cfg: {} as never,
nowMs: () => 1000,
isConfigured: () => true,
shouldHandle: () => true,
deliverRequested: async (request) => [{ id: request.id }],
finalizeResolved: finalizedResolved,
finalizeExpired: finalizedExpired,
});
await runtime.handleRequested({
id: "abc",
request: {
command: "echo abc",
},
createdAtMs: 1000,
expiresAtMs: 2000,
});
await runtime.handleRequested({
id: "xyz",
request: {
command: "echo xyz",
},
createdAtMs: 1000,
expiresAtMs: 2000,
});
await runtime.handleExpired("abc");
expect(finalizedExpired).toHaveBeenCalledTimes(1);
expect(finalizedExpired).toHaveBeenCalledWith({
request: expect.objectContaining({ id: "abc" }),
entries: [{ id: "abc" }],
});
expect(finalizedResolved).not.toHaveBeenCalled();
await runtime.handleResolved({
id: "xyz",
decision: "allow-once",
ts: 1500,
});
expect(finalizedResolved).toHaveBeenCalledTimes(1);
expect(finalizedResolved).toHaveBeenCalledWith({
request: expect.objectContaining({ id: "xyz" }),
resolved: expect.objectContaining({ id: "xyz", decision: "allow-once" }),
entries: [{ id: "xyz" }],
});
});
it("finalizes approvals that resolve while delivery is still in flight", async () => {
const pendingDelivery = createDeferred<Array<{ id: string }>>();
const finalizeResolved = vi.fn(async () => undefined);
const runtime = createExecApprovalChannelRuntime<
{ id: string },
PluginApprovalRequest,
PluginApprovalResolved
>({
label: "test/plugin-approvals",
clientDisplayName: "Test Plugin Approvals",
cfg: {} as never,
eventKinds: ["plugin"],
isConfigured: () => true,
shouldHandle: () => true,
deliverRequested: async () => pendingDelivery.promise,
finalizeResolved,
});
const requestPromise = runtime.handleRequested({
id: "plugin:abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
},
createdAtMs: 1000,
expiresAtMs: 2000,
});
await runtime.handleResolved({
id: "plugin:abc",
decision: "allow-once",
ts: 1500,
});
pendingDelivery.resolve([{ id: "plugin:abc" }]);
await requestPromise;
expect(finalizeResolved).toHaveBeenCalledWith({
request: expect.objectContaining({ id: "plugin:abc" }),
resolved: expect.objectContaining({ id: "plugin:abc", decision: "allow-once" }),
entries: [{ id: "plugin:abc" }],
});
});
it("routes gateway requests through the shared client", async () => {
const runtime = createExecApprovalChannelRuntime({
label: "test/exec-approvals",
clientDisplayName: "Test Exec Approvals",
cfg: {} as never,
isConfigured: () => true,
shouldHandle: () => true,
deliverRequested: async () => [],
finalizeResolved: async () => undefined,
});
await runtime.start();
await runtime.request("exec.approval.resolve", { id: "abc", decision: "deny" });
expect(mockGatewayClientStarts).toHaveBeenCalledTimes(1);
expect(mockGatewayClientRequests).toHaveBeenCalledWith("exec.approval.resolve", {
id: "abc",
decision: "deny",
});
});
it("can retry start after gateway client creation fails", async () => {
const boom = new Error("boom");
mockCreateOperatorApprovalsGatewayClient
.mockRejectedValueOnce(boom)
.mockResolvedValueOnce({
start: mockGatewayClientStarts,
stop: mockGatewayClientStops,
request: mockGatewayClientRequests,
});
const runtime = createExecApprovalChannelRuntime({
label: "test/exec-approvals",
clientDisplayName: "Test Exec Approvals",
cfg: {} as never,
isConfigured: () => true,
shouldHandle: () => true,
deliverRequested: async () => [],
finalizeResolved: async () => undefined,
});
await expect(runtime.start()).rejects.toThrow("boom");
await runtime.start();
expect(mockCreateOperatorApprovalsGatewayClient).toHaveBeenCalledTimes(2);
expect(mockGatewayClientStarts).toHaveBeenCalledTimes(1);
});
it("does not leave a gateway client running when stop wins the startup race", async () => {
const pendingClient = createDeferred<GatewayClient>();
mockCreateOperatorApprovalsGatewayClient.mockReturnValueOnce(pendingClient.promise);
const runtime = createExecApprovalChannelRuntime({
label: "test/exec-approvals",
clientDisplayName: "Test Exec Approvals",
cfg: {} as never,
isConfigured: () => true,
shouldHandle: () => true,
deliverRequested: async () => [],
finalizeResolved: async () => undefined,
});
const startPromise = runtime.start();
const stopPromise = runtime.stop();
pendingClient.resolve({
start: mockGatewayClientStarts,
stop: mockGatewayClientStops,
request: mockGatewayClientRequests,
});
await startPromise;
await stopPromise;
expect(mockGatewayClientStarts).not.toHaveBeenCalled();
expect(mockGatewayClientStops).toHaveBeenCalledTimes(1);
await expect(runtime.request("exec.approval.resolve", { id: "abc" })).rejects.toThrow(
"gateway client not connected",
);
});
it("logs async request handling failures from gateway events", async () => {
const runtime = createExecApprovalChannelRuntime<
{ id: string },
PluginApprovalRequest,
PluginApprovalResolved
>({
label: "test/plugin-approvals",
clientDisplayName: "Test Plugin Approvals",
cfg: {} as never,
eventKinds: ["plugin"],
isConfigured: () => true,
shouldHandle: () => true,
deliverRequested: async () => {
throw new Error("deliver failed");
},
finalizeResolved: async () => undefined,
});
await runtime.start();
const clientParams = mockCreateOperatorApprovalsGatewayClient.mock.calls[0]?.[0] as
| { onEvent?: (evt: { event: string; payload: unknown }) => void }
| undefined;
clientParams?.onEvent?.({
event: "plugin.approval.requested",
payload: {
id: "plugin:abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
},
createdAtMs: 1000,
expiresAtMs: 2000,
},
});
await vi.waitFor(() => {
expect(loggerMocks.error).toHaveBeenCalledWith(
"error handling approval request: deliver failed",
);
});
});
it("logs async expiration handling failures", async () => {
vi.useFakeTimers();
const runtime = createExecApprovalChannelRuntime<
{ id: string },
PluginApprovalRequest,
PluginApprovalResolved
>({
label: "test/plugin-approvals",
clientDisplayName: "Test Plugin Approvals",
cfg: {} as never,
nowMs: () => 1000,
eventKinds: ["plugin"],
isConfigured: () => true,
shouldHandle: () => true,
deliverRequested: async (request) => [{ id: request.id }],
finalizeResolved: async () => undefined,
finalizeExpired: async () => {
throw new Error("expire failed");
},
});
await runtime.handleRequested({
id: "plugin:abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
},
createdAtMs: 1000,
expiresAtMs: 1001,
});
await vi.advanceTimersByTimeAsync(1);
expect(loggerMocks.error).toHaveBeenCalledWith(
"error handling approval expiration: expire failed",
);
});
it("subscribes to plugin approval events when requested", async () => {
const deliverRequested = vi.fn(async (request) => [{ id: request.id }]);
const finalizeResolved = vi.fn(async () => undefined);
const runtime = createExecApprovalChannelRuntime<
{ id: string },
PluginApprovalRequest,
PluginApprovalResolved
>({
label: "test/plugin-approvals",
clientDisplayName: "Test Plugin Approvals",
cfg: {} as never,
eventKinds: ["plugin"],
isConfigured: () => true,
shouldHandle: () => true,
deliverRequested,
finalizeResolved,
});
await runtime.start();
const clientParams = mockCreateOperatorApprovalsGatewayClient.mock.calls[0]?.[0] as
| { onEvent?: (evt: { event: string; payload: unknown }) => void }
| undefined;
expect(clientParams?.onEvent).toBeTypeOf("function");
clientParams?.onEvent?.({
event: "plugin.approval.requested",
payload: {
id: "plugin:abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
},
createdAtMs: 1000,
expiresAtMs: 2000,
},
});
await vi.waitFor(() => {
expect(deliverRequested).toHaveBeenCalledWith(
expect.objectContaining({
id: "plugin:abc",
}),
);
});
clientParams?.onEvent?.({
event: "plugin.approval.resolved",
payload: {
id: "plugin:abc",
decision: "allow-once",
ts: 1500,
},
});
await vi.waitFor(() => {
expect(finalizeResolved).toHaveBeenCalledWith({
request: expect.objectContaining({ id: "plugin:abc" }),
resolved: expect.objectContaining({ id: "plugin:abc", decision: "allow-once" }),
entries: [{ id: "plugin:abc" }],
});
});
});
it("clears pending state when delivery throws", async () => {
const deliverRequested = vi
.fn<() => Promise<Array<{ id: string }>>>()
.mockRejectedValueOnce(new Error("deliver failed"))
.mockResolvedValueOnce([{ id: "abc" }]);
const finalizeResolved = vi.fn(async () => undefined);
const runtime = createExecApprovalChannelRuntime({
label: "test/delivery-failure",
clientDisplayName: "Test Delivery Failure",
cfg: {} as never,
isConfigured: () => true,
shouldHandle: () => true,
deliverRequested,
finalizeResolved,
});
await expect(
runtime.handleRequested({
id: "abc",
request: {
command: "echo abc",
},
createdAtMs: 1000,
expiresAtMs: 2000,
}),
).rejects.toThrow("deliver failed");
await runtime.handleRequested({
id: "abc",
request: {
command: "echo abc",
},
createdAtMs: 1000,
expiresAtMs: 2000,
});
await runtime.handleResolved({
id: "abc",
decision: "allow-once",
ts: 1500,
});
expect(finalizeResolved).toHaveBeenCalledWith({
request: expect.objectContaining({ id: "abc" }),
resolved: expect.objectContaining({ id: "abc", decision: "allow-once" }),
entries: [{ id: "abc" }],
});
});
});

View File

@@ -0,0 +1,285 @@
import type { OpenClawConfig } from "../config/config.js";
import type { GatewayClient } from "../gateway/client.js";
import { createOperatorApprovalsGatewayClient } from "../gateway/operator-approvals-client.js";
import type { EventFrame } from "../gateway/protocol/index.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { ExecApprovalRequest, ExecApprovalResolved } from "./exec-approvals.js";
import type { PluginApprovalRequest, PluginApprovalResolved } from "./plugin-approvals.js";
type ApprovalRequestEvent = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalResolvedEvent = ExecApprovalResolved | PluginApprovalResolved;
export type ExecApprovalChannelRuntimeEventKind = "exec" | "plugin";
type PendingApprovalEntry<
TPending,
TRequest extends ApprovalRequestEvent,
TResolved extends ApprovalResolvedEvent,
> = {
request: TRequest;
entries: TPending[];
timeoutId: NodeJS.Timeout | null;
delivering: boolean;
pendingResolution: TResolved | null;
};
export type ExecApprovalChannelRuntimeAdapter<
TPending,
TRequest extends ApprovalRequestEvent = ExecApprovalRequest,
TResolved extends ApprovalResolvedEvent = ExecApprovalResolved,
> = {
label: string;
clientDisplayName: string;
cfg: OpenClawConfig;
gatewayUrl?: string;
eventKinds?: readonly ExecApprovalChannelRuntimeEventKind[];
isConfigured: () => boolean;
shouldHandle: (request: TRequest) => boolean;
deliverRequested: (request: TRequest) => Promise<TPending[]>;
finalizeResolved: (params: {
request: TRequest;
resolved: TResolved;
entries: TPending[];
}) => Promise<void>;
finalizeExpired?: (params: {
request: TRequest;
entries: TPending[];
}) => Promise<void>;
nowMs?: () => number;
};
export type ExecApprovalChannelRuntime<
TRequest extends ApprovalRequestEvent = ExecApprovalRequest,
TResolved extends ApprovalResolvedEvent = ExecApprovalResolved,
> = {
start: () => Promise<void>;
stop: () => Promise<void>;
handleRequested: (request: TRequest) => Promise<void>;
handleResolved: (resolved: TResolved) => Promise<void>;
handleExpired: (approvalId: string) => Promise<void>;
request: <T = unknown>(method: string, params: Record<string, unknown>) => Promise<T>;
};
export function createExecApprovalChannelRuntime<
TPending,
TRequest extends ApprovalRequestEvent = ExecApprovalRequest,
TResolved extends ApprovalResolvedEvent = ExecApprovalResolved,
>(
adapter: ExecApprovalChannelRuntimeAdapter<TPending, TRequest, TResolved>,
): ExecApprovalChannelRuntime<TRequest, TResolved> {
const log = createSubsystemLogger(adapter.label);
const nowMs = adapter.nowMs ?? Date.now;
const eventKinds = new Set<ExecApprovalChannelRuntimeEventKind>(adapter.eventKinds ?? ["exec"]);
const pending = new Map<string, PendingApprovalEntry<TPending, TRequest, TResolved>>();
let gatewayClient: GatewayClient | null = null;
let started = false;
let shouldRun = false;
let startPromise: Promise<void> | null = null;
const spawn = (label: string, promise: Promise<void>): void => {
void promise.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
log.error(`${label}: ${message}`);
});
};
const clearPendingEntry = (
approvalId: string,
): PendingApprovalEntry<TPending, TRequest, TResolved> | null => {
const entry = pending.get(approvalId);
if (!entry) {
return null;
}
pending.delete(approvalId);
if (entry.timeoutId) {
clearTimeout(entry.timeoutId);
}
return entry;
};
const handleExpired = async (approvalId: string): Promise<void> => {
const entry = clearPendingEntry(approvalId);
if (!entry) {
return;
}
log.debug(`expired ${approvalId}`);
await adapter.finalizeExpired?.({
request: entry.request,
entries: entry.entries,
});
};
const handleRequested = async (request: TRequest): Promise<void> => {
if (!adapter.shouldHandle(request)) {
return;
}
log.debug(`received request ${request.id}`);
const existing = pending.get(request.id);
if (existing?.timeoutId) {
clearTimeout(existing.timeoutId);
}
const entry: PendingApprovalEntry<TPending, TRequest, TResolved> = {
request,
entries: [],
timeoutId: null,
delivering: true,
pendingResolution: null,
};
pending.set(request.id, entry);
let entries: TPending[];
try {
entries = await adapter.deliverRequested(request);
} catch (err) {
if (pending.get(request.id) === entry) {
clearPendingEntry(request.id);
}
throw err;
}
const current = pending.get(request.id);
if (current !== entry) {
return;
}
if (!entries.length) {
pending.delete(request.id);
return;
}
entry.entries = entries;
entry.delivering = false;
if (entry.pendingResolution) {
pending.delete(request.id);
log.debug(`resolved ${entry.pendingResolution.id} with ${entry.pendingResolution.decision}`);
await adapter.finalizeResolved({
request: entry.request,
resolved: entry.pendingResolution,
entries: entry.entries,
});
return;
}
const timeoutMs = Math.max(0, request.expiresAtMs - nowMs());
const timeoutId = setTimeout(() => {
spawn("error handling approval expiration", handleExpired(request.id));
}, timeoutMs);
timeoutId.unref?.();
entry.timeoutId = timeoutId;
};
const handleResolved = async (resolved: TResolved): Promise<void> => {
const entry = pending.get(resolved.id);
if (!entry) {
return;
}
if (entry.delivering) {
entry.pendingResolution = resolved;
return;
}
const finalizedEntry = clearPendingEntry(resolved.id);
if (!finalizedEntry) {
return;
}
log.debug(`resolved ${resolved.id} with ${resolved.decision}`);
await adapter.finalizeResolved({
request: finalizedEntry.request,
resolved,
entries: finalizedEntry.entries,
});
};
const handleGatewayEvent = (evt: EventFrame): void => {
if (evt.event === "exec.approval.requested" && eventKinds.has("exec")) {
spawn("error handling approval request", handleRequested(evt.payload as TRequest));
return;
}
if (evt.event === "plugin.approval.requested" && eventKinds.has("plugin")) {
spawn("error handling approval request", handleRequested(evt.payload as TRequest));
return;
}
if (evt.event === "exec.approval.resolved" && eventKinds.has("exec")) {
spawn("error handling approval resolved", handleResolved(evt.payload as TResolved));
return;
}
if (evt.event === "plugin.approval.resolved" && eventKinds.has("plugin")) {
spawn("error handling approval resolved", handleResolved(evt.payload as TResolved));
}
};
return {
async start(): Promise<void> {
if (started) {
return;
}
if (startPromise) {
await startPromise;
return;
}
shouldRun = true;
startPromise = (async () => {
if (!adapter.isConfigured()) {
log.debug("disabled");
return;
}
const client = await createOperatorApprovalsGatewayClient({
config: adapter.cfg,
gatewayUrl: adapter.gatewayUrl,
clientDisplayName: adapter.clientDisplayName,
onEvent: handleGatewayEvent,
onHelloOk: () => {
log.debug("connected to gateway");
},
onConnectError: (err) => {
log.error(`connect error: ${err.message}`);
},
onClose: (code, reason) => {
log.debug(`gateway closed: ${code} ${reason}`);
},
});
if (!shouldRun) {
client.stop();
return;
}
client.start();
gatewayClient = client;
started = true;
})().finally(() => {
startPromise = null;
});
await startPromise;
},
async stop(): Promise<void> {
shouldRun = false;
if (startPromise) {
await startPromise.catch(() => {});
}
if (!started && !gatewayClient) {
return;
}
started = false;
for (const entry of pending.values()) {
if (entry.timeoutId) {
clearTimeout(entry.timeoutId);
}
}
pending.clear();
gatewayClient?.stop();
gatewayClient = null;
log.debug("stopped");
},
handleRequested,
handleResolved,
handleExpired,
async request<T = unknown>(method: string, params: Record<string, unknown>): Promise<T> {
if (!gatewayClient) {
throw new Error(`${adapter.label}: gateway client not connected`);
}
return (await gatewayClient.request(method, params)) as T;
},
};
}

View File

@@ -1,10 +1,14 @@
import { describe, expect, it } from "vitest";
import type { ReplyPayload } from "../auto-reply/types.js";
import {
buildExecApprovalActionDescriptors,
buildExecApprovalCommandText,
buildExecApprovalInteractiveReply,
buildExecApprovalPendingReplyPayload,
buildExecApprovalUnavailableReplyPayload,
getExecApprovalApproverDmNoticeText,
getExecApprovalReplyMetadata,
parseExecApprovalCommandText,
} from "./exec-approval-reply.js";
describe("exec approval reply helpers", () => {
@@ -166,6 +170,77 @@ describe("exec approval reply helpers", () => {
expect(payload.text).toContain("Expires in: 30m");
});
it("builds shared exec approval action descriptors and interactive replies", () => {
expect(
buildExecApprovalActionDescriptors({
approvalCommandId: "req-1",
}),
).toEqual([
{
decision: "allow-once",
label: "Allow Once",
style: "success",
command: "/approve req-1 allow-once",
},
{
decision: "allow-always",
label: "Allow Always",
style: "primary",
command: "/approve req-1 always",
},
{
decision: "deny",
label: "Deny",
style: "danger",
command: "/approve req-1 deny",
},
]);
expect(
buildExecApprovalInteractiveReply({
approvalCommandId: "req-1",
}),
).toEqual({
blocks: [
{
type: "buttons",
buttons: [
{ label: "Allow Once", value: "/approve req-1 allow-once", style: "success" },
{ label: "Allow Always", value: "/approve req-1 always", style: "primary" },
{ label: "Deny", value: "/approve req-1 deny", style: "danger" },
],
},
],
});
});
it("builds and parses shared exec approval command text", () => {
expect(
buildExecApprovalCommandText({
approvalCommandId: "req-1",
decision: "allow-always",
}),
).toBe("/approve req-1 always");
expect(parseExecApprovalCommandText("/approve req-1 deny")).toEqual({
approvalId: "req-1",
decision: "deny",
});
expect(parseExecApprovalCommandText("/approve@clover req-1 allow-once")).toEqual({
approvalId: "req-1",
decision: "allow-once",
});
expect(parseExecApprovalCommandText(" /approve req-1 always")).toEqual({
approvalId: "req-1",
decision: "allow-always",
});
expect(parseExecApprovalCommandText("/approve req-1 allow-always")).toEqual({
approvalId: "req-1",
decision: "allow-always",
});
expect(parseExecApprovalCommandText("/approve req-1 maybe")).toBeNull();
});
it("builds unavailable payloads for approver DMs", () => {
expect(
buildExecApprovalUnavailableReplyPayload({

View File

@@ -14,6 +14,13 @@ export type ExecApprovalReplyMetadata = {
allowedDecisions?: readonly ExecApprovalReplyDecision[];
};
export type ExecApprovalActionDescriptor = {
decision: ExecApprovalReplyDecision;
label: string;
style: NonNullable<InteractiveReplyButton["style"]>;
command: string;
};
export type ExecApprovalPendingReplyParams = {
warningText?: string;
approvalId: string;
@@ -36,40 +43,71 @@ export type ExecApprovalUnavailableReplyParams = {
const DEFAULT_ALLOWED_DECISIONS = ["allow-once", "allow-always", "deny"] as const;
function buildApprovalDecisionCommandValue(params: {
approvalId: string;
export function buildExecApprovalCommandText(params: {
approvalCommandId: string;
decision: ExecApprovalReplyDecision;
}): string {
return `/approve ${params.approvalId} ${params.decision === "allow-always" ? "always" : params.decision}`;
return `/approve ${params.approvalCommandId} ${params.decision === "allow-always" ? "always" : params.decision}`;
}
export function buildExecApprovalActionDescriptors(params: {
approvalCommandId: string;
allowedDecisions?: readonly ExecApprovalReplyDecision[];
}): ExecApprovalActionDescriptor[] {
const approvalCommandId = params.approvalCommandId.trim();
if (!approvalCommandId) {
return [];
}
const allowedDecisions = params.allowedDecisions ?? DEFAULT_ALLOWED_DECISIONS;
const descriptors: ExecApprovalActionDescriptor[] = [];
if (allowedDecisions.includes("allow-once")) {
descriptors.push({
decision: "allow-once",
label: "Allow Once",
style: "success",
command: buildExecApprovalCommandText({
approvalCommandId,
decision: "allow-once",
}),
});
}
if (allowedDecisions.includes("allow-always")) {
descriptors.push({
decision: "allow-always",
label: "Allow Always",
style: "primary",
command: buildExecApprovalCommandText({
approvalCommandId,
decision: "allow-always",
}),
});
}
if (allowedDecisions.includes("deny")) {
descriptors.push({
decision: "deny",
label: "Deny",
style: "danger",
command: buildExecApprovalCommandText({
approvalCommandId,
decision: "deny",
}),
});
}
return descriptors;
}
function buildApprovalInteractiveButtons(
allowedDecisions: readonly ExecApprovalReplyDecision[],
approvalId: string,
): InteractiveReplyButton[] {
const buttons: InteractiveReplyButton[] = [];
if (allowedDecisions.includes("allow-once")) {
buttons.push({
label: "Allow Once",
value: buildApprovalDecisionCommandValue({ approvalId, decision: "allow-once" }),
style: "success",
});
}
if (allowedDecisions.includes("allow-always")) {
buttons.push({
label: "Allow Always",
value: buildApprovalDecisionCommandValue({ approvalId, decision: "allow-always" }),
style: "primary",
});
}
if (allowedDecisions.includes("deny")) {
buttons.push({
label: "Deny",
value: buildApprovalDecisionCommandValue({ approvalId, decision: "deny" }),
style: "danger",
});
}
return buttons;
return buildExecApprovalActionDescriptors({
approvalCommandId: approvalId,
allowedDecisions,
}).map((descriptor) => ({
label: descriptor.label,
value: descriptor.command,
style: descriptor.style,
}));
}
export function buildApprovalInteractiveReply(params: {
@@ -83,10 +121,37 @@ export function buildApprovalInteractiveReply(params: {
return buttons.length > 0 ? { blocks: [{ type: "buttons", buttons }] } : undefined;
}
export function buildExecApprovalInteractiveReply(params: {
approvalCommandId: string;
allowedDecisions?: readonly ExecApprovalReplyDecision[];
}): InteractiveReply | undefined {
return buildApprovalInteractiveReply({
approvalId: params.approvalCommandId,
allowedDecisions: params.allowedDecisions,
});
}
export function getExecApprovalApproverDmNoticeText(): string {
return "Approval required. I sent approval DMs to the approvers for this account.";
}
export function parseExecApprovalCommandText(
raw: string,
): { approvalId: string; decision: ExecApprovalReplyDecision } | null {
const trimmed = raw.trim();
const match = trimmed.match(
/^\/approve(?:@[^\s]+)?\s+([A-Za-z0-9][A-Za-z0-9._:-]*)\s+(allow-once|allow-always|always|deny)\b/i,
);
if (!match) {
return null;
}
const rawDecision = match[2].toLowerCase();
return {
approvalId: match[1],
decision: rawDecision === "always" ? "allow-always" : (rawDecision as ExecApprovalReplyDecision),
};
}
export function formatExecApprovalExpiresIn(expiresAtMs: number, nowMs: number): string {
const totalSeconds = Math.max(0, Math.round((expiresAtMs - nowMs) / 1000));
if (totalSeconds < 60) {

View File

@@ -8,6 +8,7 @@ export * from "../infra/diagnostic-flags.js";
export * from "../infra/env.js";
export * from "../infra/errors.js";
export * from "../infra/exec-approval-command-display.ts";
export * from "../infra/exec-approval-channel-runtime.ts";
export * from "../infra/exec-approval-reply.ts";
export * from "../infra/exec-approval-session-target.ts";
export * from "../infra/exec-approvals.ts";