Merge branch 'main' into ui/dashboard-v2.1.3
This commit is contained in:
commit
20702f8102
@ -132,6 +132,7 @@
|
||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
||||
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
||||
- For targeted/local debugging, keep using the wrapper: `pnpm test -- <path-or-filter> [vitest args...]` (for example `pnpm test -- src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses wrapper config/profile/pool routing.
|
||||
- Do not set test workers above 16; tried already.
|
||||
- If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs.
|
||||
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
||||
|
||||
@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Dashboard/chat UI: stop reloading full chat history on every live tool result in dashboard v2 so tool-heavy runs no longer trigger UI freeze/re-render storms while the final event still refreshes persisted history. (#45541) Thanks @BunsDev.
|
||||
- iMessage/remote attachments: reject unsafe remote attachment paths before spawning SCP, so sender-controlled filenames can no longer inject shell metacharacters into remote media staging. Thanks @lintsinghua.
|
||||
- Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08.
|
||||
- Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn.
|
||||
- Browser/existing-session: accept text-only `list_pages` and `new_page` responses from Chrome DevTools MCP so live-session tab discovery and new-tab open flows keep working when the server omits structured page metadata.
|
||||
|
||||
@ -398,6 +398,10 @@ Notes:
|
||||
session only.
|
||||
- OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here, not
|
||||
the legacy default-profile remote debugging port workflow.
|
||||
- Existing-session screenshots support page captures and `--ref` element
|
||||
captures from snapshots, but not CSS `--element` selectors.
|
||||
- Existing-session `wait --url` supports exact, substring, and glob patterns
|
||||
like other browser drivers. `wait --load networkidle` is not supported yet.
|
||||
- Some features still require the extension relay or managed browser path, such
|
||||
as PDF export and download interception.
|
||||
- Leave the relay loopback-only by default. If the relay must be reachable from a different network namespace (for example Gateway in WSL2, Chrome on Windows), set `browser.relayBindHost` to an explicit bind address such as `0.0.0.0` while keeping the surrounding network private and authenticated.
|
||||
|
||||
@ -2,7 +2,7 @@ import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { postMultipartFormData } from "./multipart.js";
|
||||
import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
|
||||
import {
|
||||
getCachedBlueBubblesPrivateApiStatus,
|
||||
isBlueBubblesPrivateApiStatusEnabled,
|
||||
@ -262,12 +262,7 @@ export async function sendBlueBubblesAttachment(params: {
|
||||
timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
throw new Error(
|
||||
`BlueBubbles attachment send failed (${res.status}): ${errorText || "unknown"}`,
|
||||
);
|
||||
}
|
||||
await assertMultipartActionOk(res, "attachment send");
|
||||
|
||||
const responseBody = await res.text();
|
||||
if (!responseBody) {
|
||||
|
||||
@ -29,6 +29,11 @@ describe("chat", () => {
|
||||
});
|
||||
}
|
||||
|
||||
function mockTwoOkTextResponses() {
|
||||
mockOkTextResponse();
|
||||
mockOkTextResponse();
|
||||
}
|
||||
|
||||
async function expectCalledUrlIncludesPassword(params: {
|
||||
password: string;
|
||||
invoke: () => Promise<void>;
|
||||
@ -198,15 +203,7 @@ describe("chat", () => {
|
||||
});
|
||||
|
||||
it("uses POST for start and DELETE for stop", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
mockTwoOkTextResponses();
|
||||
|
||||
await sendBlueBubblesTyping("iMessage;-;+15551234567", true, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
@ -442,15 +439,7 @@ describe("chat", () => {
|
||||
});
|
||||
|
||||
it("adds and removes participant using matching endpoint", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
mockTwoOkTextResponses();
|
||||
|
||||
await addBlueBubblesParticipant("chat-guid", "+15551234567", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
|
||||
@ -2,7 +2,7 @@ import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { postMultipartFormData } from "./multipart.js";
|
||||
import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
|
||||
@ -26,14 +26,6 @@ function assertPrivateApiEnabled(accountId: string, feature: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function assertBlueBubblesActionOk(response: Response, action: string): Promise<void> {
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
const errorText = await response.text().catch(() => "");
|
||||
throw new Error(`BlueBubbles ${action} failed (${response.status}): ${errorText || "unknown"}`);
|
||||
}
|
||||
|
||||
function resolvePartIndex(partIndex: number | undefined): number {
|
||||
return typeof partIndex === "number" ? partIndex : 0;
|
||||
}
|
||||
@ -63,7 +55,7 @@ async function sendBlueBubblesChatEndpointRequest(params: {
|
||||
{ method: params.method },
|
||||
params.opts.timeoutMs,
|
||||
);
|
||||
await assertBlueBubblesActionOk(res, params.action);
|
||||
await assertMultipartActionOk(res, params.action);
|
||||
}
|
||||
|
||||
async function sendPrivateApiJsonRequest(params: {
|
||||
@ -89,7 +81,7 @@ async function sendPrivateApiJsonRequest(params: {
|
||||
}
|
||||
|
||||
const res = await blueBubblesFetchWithTimeout(url, request, params.opts.timeoutMs);
|
||||
await assertBlueBubblesActionOk(res, params.action);
|
||||
await assertMultipartActionOk(res, params.action);
|
||||
}
|
||||
|
||||
export async function markBlueBubblesChatRead(
|
||||
@ -327,8 +319,5 @@ export async function setGroupIconBlueBubbles(
|
||||
timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => "");
|
||||
throw new Error(`BlueBubbles setGroupIcon failed (${res.status}): ${errorText || "unknown"}`);
|
||||
}
|
||||
await assertMultipartActionOk(res, "setGroupIcon");
|
||||
}
|
||||
|
||||
@ -1,18 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
|
||||
|
||||
function createFallbackDmPayload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
guid: "msg-1",
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
handle: null,
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("normalizeWebhookMessage", () => {
|
||||
it("falls back to DM chatGuid handle when sender handle is missing", () => {
|
||||
const result = normalizeWebhookMessage({
|
||||
type: "new-message",
|
||||
data: {
|
||||
guid: "msg-1",
|
||||
data: createFallbackDmPayload({
|
||||
text: "hello",
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
handle: null,
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
@ -78,15 +84,11 @@ describe("normalizeWebhookReaction", () => {
|
||||
it("falls back to DM chatGuid handle when reaction sender handle is missing", () => {
|
||||
const result = normalizeWebhookReaction({
|
||||
type: "updated-message",
|
||||
data: {
|
||||
data: createFallbackDmPayload({
|
||||
guid: "msg-2",
|
||||
associatedMessageGuid: "p:0/msg-1",
|
||||
associatedMessageType: 2000,
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
handle: null,
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
@ -30,3 +30,11 @@ export async function postMultipartFormData(params: {
|
||||
params.timeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
export async function assertMultipartActionOk(response: Response, action: string): Promise<void> {
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
const errorText = await response.text().catch(() => "");
|
||||
throw new Error(`BlueBubbles ${action} failed (${response.status}): ${errorText || "unknown"}`);
|
||||
}
|
||||
|
||||
@ -108,13 +108,21 @@ function resolveScheme(
|
||||
return cfg.gateway?.tls?.enabled === true ? "wss" : "ws";
|
||||
}
|
||||
|
||||
function isPrivateIPv4(address: string): boolean {
|
||||
function parseIPv4Octets(address: string): [number, number, number, number] | null {
|
||||
const parts = address.split(".");
|
||||
if (parts.length != 4) {
|
||||
return false;
|
||||
if (parts.length !== 4) {
|
||||
return null;
|
||||
}
|
||||
const octets = parts.map((part) => Number.parseInt(part, 10));
|
||||
if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) {
|
||||
return null;
|
||||
}
|
||||
return octets as [number, number, number, number];
|
||||
}
|
||||
|
||||
function isPrivateIPv4(address: string): boolean {
|
||||
const octets = parseIPv4Octets(address);
|
||||
if (!octets) {
|
||||
return false;
|
||||
}
|
||||
const [a, b] = octets;
|
||||
@ -131,12 +139,8 @@ function isPrivateIPv4(address: string): boolean {
|
||||
}
|
||||
|
||||
function isTailnetIPv4(address: string): boolean {
|
||||
const parts = address.split(".");
|
||||
if (parts.length !== 4) {
|
||||
return false;
|
||||
}
|
||||
const octets = parts.map((part) => Number.parseInt(part, 10));
|
||||
if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) {
|
||||
const octets = parseIPv4Octets(address);
|
||||
if (!octets) {
|
||||
return false;
|
||||
}
|
||||
const [a, b] = octets;
|
||||
|
||||
@ -9,6 +9,19 @@ describe("createDiffsHttpHandler", () => {
|
||||
let store: DiffArtifactStore;
|
||||
let cleanupRootDir: () => Promise<void>;
|
||||
|
||||
async function handleLocalGet(url: string) {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
return { handled, res };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-"));
|
||||
});
|
||||
@ -19,16 +32,7 @@ describe("createDiffsHttpHandler", () => {
|
||||
|
||||
it("serves a stored diff document", async () => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: artifact.viewerPath,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
const { handled, res } = await handleLocalGet(artifact.viewerPath);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
@ -38,15 +42,8 @@ describe("createDiffsHttpHandler", () => {
|
||||
|
||||
it("rejects invalid tokens", async () => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: artifact.viewerPath.replace(artifact.token, "bad-token"),
|
||||
}),
|
||||
res,
|
||||
const { handled, res } = await handleLocalGet(
|
||||
artifact.viewerPath.replace(artifact.token, "bad-token"),
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
|
||||
@ -135,9 +135,7 @@ describe("diffs tool", () => {
|
||||
mode: "file",
|
||||
});
|
||||
|
||||
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
|
||||
expect((result?.details as Record<string, unknown>).mode).toBe("file");
|
||||
expect((result?.details as Record<string, unknown>).viewerUrl).toBeUndefined();
|
||||
expectArtifactOnlyFileResult(screenshotter, result);
|
||||
});
|
||||
|
||||
it("honors ttlSeconds for artifact-only file output", async () => {
|
||||
@ -227,9 +225,7 @@ describe("diffs tool", () => {
|
||||
after: "two\n",
|
||||
});
|
||||
|
||||
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
|
||||
expect((result?.details as Record<string, unknown>).mode).toBe("file");
|
||||
expect((result?.details as Record<string, unknown>).viewerUrl).toBeUndefined();
|
||||
expectArtifactOnlyFileResult(screenshotter, result);
|
||||
});
|
||||
|
||||
it("falls back to view output when both mode cannot render an image", async () => {
|
||||
@ -434,6 +430,15 @@ function createToolWithScreenshotter(
|
||||
});
|
||||
}
|
||||
|
||||
function expectArtifactOnlyFileResult(
|
||||
screenshotter: DiffScreenshotter,
|
||||
result: { details?: Record<string, unknown> } | null | undefined,
|
||||
) {
|
||||
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
|
||||
expect((result?.details as Record<string, unknown>).mode).toBe("file");
|
||||
expect((result?.details as Record<string, unknown>).viewerUrl).toBeUndefined();
|
||||
}
|
||||
|
||||
function createPngScreenshotter(
|
||||
params: {
|
||||
assertHtml?: (html: string) => void;
|
||||
|
||||
@ -75,6 +75,27 @@ function getRequiredHandler(
|
||||
return handler;
|
||||
}
|
||||
|
||||
function resolveSubagentDeliveryTargetForTest(requesterOrigin: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
to: string;
|
||||
threadId?: string;
|
||||
}) {
|
||||
const handlers = registerHandlersForTest();
|
||||
const handler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
return handler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin,
|
||||
childRunId: "run-1",
|
||||
spawnMode: "session",
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
function createSpawnEvent(overrides?: {
|
||||
childSessionKey?: string;
|
||||
agentId?: string;
|
||||
@ -324,25 +345,12 @@ describe("discord subagent hook handlers", () => {
|
||||
hookMocks.listThreadBindingsBySessionKey.mockReturnValueOnce([
|
||||
{ accountId: "work", threadId: "777" },
|
||||
]);
|
||||
const handlers = registerHandlersForTest();
|
||||
const handler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
|
||||
const result = handler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:123",
|
||||
threadId: "777",
|
||||
},
|
||||
childRunId: "run-1",
|
||||
spawnMode: "session",
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
const result = resolveSubagentDeliveryTargetForTest({
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:123",
|
||||
threadId: "777",
|
||||
});
|
||||
|
||||
expect(hookMocks.listThreadBindingsBySessionKey).toHaveBeenCalledWith({
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
@ -364,24 +372,11 @@ describe("discord subagent hook handlers", () => {
|
||||
{ accountId: "work", threadId: "777" },
|
||||
{ accountId: "work", threadId: "888" },
|
||||
]);
|
||||
const handlers = registerHandlersForTest();
|
||||
const handler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
|
||||
const result = handler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:123",
|
||||
},
|
||||
childRunId: "run-1",
|
||||
spawnMode: "session",
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
const result = resolveSubagentDeliveryTargetForTest({
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:123",
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
@ -9,6 +9,23 @@ import type { FeishuConfig } from "./types.js";
|
||||
|
||||
const asConfig = (value: Partial<FeishuConfig>) => value as FeishuConfig;
|
||||
|
||||
function makeDefaultAndRouterAccounts() {
|
||||
return {
|
||||
default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
|
||||
"router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret
|
||||
};
|
||||
}
|
||||
|
||||
function expectExplicitDefaultAccountSelection(
|
||||
account: ReturnType<typeof resolveFeishuAccount>,
|
||||
appId: string,
|
||||
) {
|
||||
expect(account.accountId).toBe("router-d");
|
||||
expect(account.selectionSource).toBe("explicit-default");
|
||||
expect(account.configured).toBe(true);
|
||||
expect(account.appId).toBe(appId);
|
||||
}
|
||||
|
||||
function withEnvVar(key: string, value: string | undefined, run: () => void) {
|
||||
const prev = process.env[key];
|
||||
if (value === undefined) {
|
||||
@ -44,10 +61,7 @@ describe("resolveDefaultFeishuAccountId", () => {
|
||||
channels: {
|
||||
feishu: {
|
||||
defaultAccount: "router-d",
|
||||
accounts: {
|
||||
default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
|
||||
"router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret
|
||||
},
|
||||
accounts: makeDefaultAndRouterAccounts(),
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -278,10 +292,7 @@ describe("resolveFeishuAccount", () => {
|
||||
};
|
||||
|
||||
const account = resolveFeishuAccount({ cfg: cfg as never, accountId: undefined });
|
||||
expect(account.accountId).toBe("router-d");
|
||||
expect(account.selectionSource).toBe("explicit-default");
|
||||
expect(account.configured).toBe(true);
|
||||
expect(account.appId).toBe("top_level_app");
|
||||
expectExplicitDefaultAccountSelection(account, "top_level_app");
|
||||
});
|
||||
|
||||
it("uses configured default account when accountId is omitted", () => {
|
||||
@ -298,10 +309,7 @@ describe("resolveFeishuAccount", () => {
|
||||
};
|
||||
|
||||
const account = resolveFeishuAccount({ cfg: cfg as never, accountId: undefined });
|
||||
expect(account.accountId).toBe("router-d");
|
||||
expect(account.selectionSource).toBe("explicit-default");
|
||||
expect(account.configured).toBe(true);
|
||||
expect(account.appId).toBe("cli_router");
|
||||
expectExplicitDefaultAccountSelection(account, "cli_router");
|
||||
});
|
||||
|
||||
it("keeps explicit accountId selection", () => {
|
||||
@ -309,10 +317,7 @@ describe("resolveFeishuAccount", () => {
|
||||
channels: {
|
||||
feishu: {
|
||||
defaultAccount: "router-d",
|
||||
accounts: {
|
||||
default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
|
||||
"router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret
|
||||
},
|
||||
accounts: makeDefaultAndRouterAccounts(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,6 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FeishuConfigSchema, FeishuGroupSchema } from "./config-schema.js";
|
||||
|
||||
function expectSchemaIssue(
|
||||
result: ReturnType<typeof FeishuConfigSchema.safeParse>,
|
||||
issuePath: string,
|
||||
) {
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.some((issue) => issue.path.join(".") === issuePath)).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
describe("FeishuConfigSchema webhook validation", () => {
|
||||
it("applies top-level defaults", () => {
|
||||
const result = FeishuConfigSchema.parse({});
|
||||
@ -39,12 +49,7 @@ describe("FeishuConfigSchema webhook validation", () => {
|
||||
appSecret: "secret_top", // pragma: allowlist secret
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((issue) => issue.path.join(".") === "verificationToken"),
|
||||
).toBe(true);
|
||||
}
|
||||
expectSchemaIssue(result, "verificationToken");
|
||||
});
|
||||
|
||||
it("rejects top-level webhook mode without encryptKey", () => {
|
||||
@ -55,10 +60,7 @@ describe("FeishuConfigSchema webhook validation", () => {
|
||||
appSecret: "secret_top", // pragma: allowlist secret
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.some((issue) => issue.path.join(".") === "encryptKey")).toBe(true);
|
||||
}
|
||||
expectSchemaIssue(result, "encryptKey");
|
||||
});
|
||||
|
||||
it("accepts top-level webhook mode with verificationToken and encryptKey", () => {
|
||||
@ -84,14 +86,7 @@ describe("FeishuConfigSchema webhook validation", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some(
|
||||
(issue) => issue.path.join(".") === "accounts.main.verificationToken",
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
expectSchemaIssue(result, "accounts.main.verificationToken");
|
||||
});
|
||||
|
||||
it("rejects account webhook mode without encryptKey", () => {
|
||||
@ -106,12 +101,7 @@ describe("FeishuConfigSchema webhook validation", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((issue) => issue.path.join(".") === "accounts.main.encryptKey"),
|
||||
).toBe(true);
|
||||
}
|
||||
expectSchemaIssue(result, "accounts.main.encryptKey");
|
||||
});
|
||||
|
||||
it("accepts account webhook mode inheriting top-level verificationToken and encryptKey", () => {
|
||||
|
||||
@ -64,18 +64,21 @@ function expectMediaTimeoutClientConfigured(): void {
|
||||
);
|
||||
}
|
||||
|
||||
function mockResolvedFeishuAccount() {
|
||||
resolveFeishuAccountMock.mockReturnValue({
|
||||
configured: true,
|
||||
accountId: "main",
|
||||
config: {},
|
||||
appId: "app_id",
|
||||
appSecret: "app_secret",
|
||||
domain: "feishu",
|
||||
});
|
||||
}
|
||||
|
||||
describe("sendMediaFeishu msg_type routing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
resolveFeishuAccountMock.mockReturnValue({
|
||||
configured: true,
|
||||
accountId: "main",
|
||||
config: {},
|
||||
appId: "app_id",
|
||||
appSecret: "app_secret",
|
||||
domain: "feishu",
|
||||
});
|
||||
mockResolvedFeishuAccount();
|
||||
|
||||
normalizeFeishuTargetMock.mockReturnValue("ou_target");
|
||||
resolveReceiveIdTypeMock.mockReturnValue("open_id");
|
||||
@ -483,15 +486,7 @@ describe("sanitizeFileNameForUpload", () => {
|
||||
describe("downloadMessageResourceFeishu", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
resolveFeishuAccountMock.mockReturnValue({
|
||||
configured: true,
|
||||
accountId: "main",
|
||||
config: {},
|
||||
appId: "app_id",
|
||||
appSecret: "app_secret",
|
||||
domain: "feishu",
|
||||
});
|
||||
mockResolvedFeishuAccount();
|
||||
|
||||
createFeishuClientMock.mockReturnValue({
|
||||
im: {
|
||||
|
||||
@ -78,6 +78,25 @@ async function resolveReactionWithLookup(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveNonBotReaction(params?: { cfg?: ClawdbotConfig; uuid?: () => string }) {
|
||||
return await resolveReactionSyntheticEvent({
|
||||
cfg: params?.cfg ?? cfg,
|
||||
accountId: "default",
|
||||
event: makeReactionEvent(),
|
||||
botOpenId: "ou_bot",
|
||||
fetchMessage: async () => ({
|
||||
messageId: "om_msg1",
|
||||
chatId: "oc_group",
|
||||
chatType: "group",
|
||||
senderOpenId: "ou_other",
|
||||
senderType: "user",
|
||||
content: "hello",
|
||||
contentType: "text",
|
||||
}),
|
||||
...(params?.uuid ? { uuid: params.uuid } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
type FeishuMention = NonNullable<FeishuMessageEvent["message"]["mentions"]>[number];
|
||||
|
||||
function buildDebounceConfig(): ClawdbotConfig {
|
||||
@ -179,6 +198,19 @@ function getFirstDispatchedEvent(): FeishuMessageEvent {
|
||||
return firstParams.event;
|
||||
}
|
||||
|
||||
function expectSingleDispatchedEvent(): FeishuMessageEvent {
|
||||
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
||||
return getFirstDispatchedEvent();
|
||||
}
|
||||
|
||||
function expectParsedFirstDispatchedEvent(botOpenId = "ou_bot") {
|
||||
const dispatched = expectSingleDispatchedEvent();
|
||||
return {
|
||||
dispatched,
|
||||
parsed: parseFeishuMessageEvent(dispatched, botOpenId),
|
||||
};
|
||||
}
|
||||
|
||||
function setDedupPassThroughMocks(): void {
|
||||
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
||||
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
||||
@ -203,6 +235,13 @@ async function enqueueDebouncedMessage(
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function setStaleRetryMocks(messageId = "om_old") {
|
||||
vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(`:${messageId}`));
|
||||
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
|
||||
async (currentMessageId) => currentMessageId === messageId,
|
||||
);
|
||||
}
|
||||
|
||||
describe("resolveReactionSyntheticEvent", () => {
|
||||
it("filters app self-reactions", async () => {
|
||||
const event = makeReactionEvent({ operator_type: "app" });
|
||||
@ -262,28 +301,12 @@ describe("resolveReactionSyntheticEvent", () => {
|
||||
});
|
||||
|
||||
it("filters reactions on non-bot messages", async () => {
|
||||
const event = makeReactionEvent();
|
||||
const result = await resolveReactionSyntheticEvent({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
event,
|
||||
botOpenId: "ou_bot",
|
||||
fetchMessage: async () => ({
|
||||
messageId: "om_msg1",
|
||||
chatId: "oc_group",
|
||||
chatType: "group",
|
||||
senderOpenId: "ou_other",
|
||||
senderType: "user",
|
||||
content: "hello",
|
||||
contentType: "text",
|
||||
}),
|
||||
});
|
||||
const result = await resolveNonBotReaction();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("allows non-bot reactions when reactionNotifications is all", async () => {
|
||||
const event = makeReactionEvent();
|
||||
const result = await resolveReactionSyntheticEvent({
|
||||
const result = await resolveNonBotReaction({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
@ -291,18 +314,6 @@ describe("resolveReactionSyntheticEvent", () => {
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig,
|
||||
accountId: "default",
|
||||
event,
|
||||
botOpenId: "ou_bot",
|
||||
fetchMessage: async () => ({
|
||||
messageId: "om_msg1",
|
||||
chatId: "oc_group",
|
||||
chatType: "group",
|
||||
senderOpenId: "ou_other",
|
||||
senderType: "user",
|
||||
content: "hello",
|
||||
contentType: "text",
|
||||
}),
|
||||
uuid: () => "fixed-uuid",
|
||||
});
|
||||
expect(result?.message.message_id).toBe("om_msg1:reaction:THUMBSUP:fixed-uuid");
|
||||
@ -457,8 +468,7 @@ describe("Feishu inbound debounce regressions", () => {
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
||||
const dispatched = getFirstDispatchedEvent();
|
||||
const dispatched = expectSingleDispatchedEvent();
|
||||
const mergedMentions = dispatched.message.mentions ?? [];
|
||||
expect(mergedMentions.some((mention) => mention.id.open_id === "ou_bot")).toBe(true);
|
||||
expect(mergedMentions.some((mention) => mention.id.open_id === "ou_user_a")).toBe(false);
|
||||
@ -517,9 +527,7 @@ describe("Feishu inbound debounce regressions", () => {
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
||||
const dispatched = getFirstDispatchedEvent();
|
||||
const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
|
||||
const { dispatched, parsed } = expectParsedFirstDispatchedEvent();
|
||||
expect(parsed.mentionedBot).toBe(true);
|
||||
expect(parsed.mentionTargets).toBeUndefined();
|
||||
const mergedMentions = dispatched.message.mentions ?? [];
|
||||
@ -547,19 +555,14 @@ describe("Feishu inbound debounce regressions", () => {
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
||||
const dispatched = getFirstDispatchedEvent();
|
||||
const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
|
||||
const { parsed } = expectParsedFirstDispatchedEvent();
|
||||
expect(parsed.mentionedBot).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes previously processed retries from combined debounce text", async () => {
|
||||
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
||||
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
||||
vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
|
||||
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
|
||||
async (messageId) => messageId === "om_old",
|
||||
);
|
||||
setStaleRetryMocks();
|
||||
const onMessage = await setupDebounceMonitor();
|
||||
|
||||
await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
|
||||
@ -576,8 +579,7 @@ describe("Feishu inbound debounce regressions", () => {
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
||||
const dispatched = getFirstDispatchedEvent();
|
||||
const dispatched = expectSingleDispatchedEvent();
|
||||
expect(dispatched.message.message_id).toBe("om_new_2");
|
||||
const combined = JSON.parse(dispatched.message.content) as { text?: string };
|
||||
expect(combined.text).toBe("first\nsecond");
|
||||
@ -586,10 +588,7 @@ describe("Feishu inbound debounce regressions", () => {
|
||||
it("uses latest fresh message id when debounce batch ends with stale retry", async () => {
|
||||
const recordSpy = vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
||||
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
||||
vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
|
||||
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
|
||||
async (messageId) => messageId === "om_old",
|
||||
);
|
||||
setStaleRetryMocks();
|
||||
const onMessage = await setupDebounceMonitor();
|
||||
|
||||
await onMessage(createTextEvent({ messageId: "om_new", text: "fresh" }));
|
||||
@ -600,8 +599,7 @@ describe("Feishu inbound debounce regressions", () => {
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
||||
const dispatched = getFirstDispatchedEvent();
|
||||
const dispatched = expectSingleDispatchedEvent();
|
||||
expect(dispatched.message.message_id).toBe("om_new");
|
||||
const combined = JSON.parse(dispatched.message.content) as { text?: string };
|
||||
expect(combined.text).toBe("fresh");
|
||||
|
||||
@ -1,35 +1,19 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
|
||||
import {
|
||||
createFeishuClientMockModule,
|
||||
createFeishuRuntimeMockModule,
|
||||
} from "./monitor.test-mocks.js";
|
||||
|
||||
const probeFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const feishuClientMockModule = vi.hoisted(() => ({
|
||||
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
|
||||
createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
|
||||
}));
|
||||
const feishuRuntimeMockModule = vi.hoisted(() => ({
|
||||
getFeishuRuntime: () => ({
|
||||
channel: {
|
||||
debounce: {
|
||||
resolveInboundDebounceMs: () => 0,
|
||||
createInboundDebouncer: () => ({
|
||||
enqueue: async () => {},
|
||||
flushKey: async () => {},
|
||||
}),
|
||||
},
|
||||
text: {
|
||||
hasControlCommand: () => false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
probeFeishu: probeFeishuMock,
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => feishuClientMockModule);
|
||||
vi.mock("./runtime.js", () => feishuRuntimeMockModule);
|
||||
vi.mock("./client.js", () => createFeishuClientMockModule());
|
||||
vi.mock("./runtime.js", () => createFeishuRuntimeMockModule());
|
||||
|
||||
function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig {
|
||||
return {
|
||||
@ -52,6 +36,12 @@ function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
|
||||
async function waitForStartedAccount(started: string[], accountId: string) {
|
||||
for (let i = 0; i < 10 && !started.includes(accountId); i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
stopFeishuMonitor();
|
||||
});
|
||||
@ -116,10 +106,7 @@ describe("Feishu monitor startup preflight", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
for (let i = 0; i < 10 && !started.includes("beta"); i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
await waitForStartedAccount(started, "beta");
|
||||
expect(started).toEqual(["alpha", "beta"]);
|
||||
expect(started.filter((accountId) => accountId === "alpha")).toHaveLength(1);
|
||||
} finally {
|
||||
@ -153,10 +140,7 @@ describe("Feishu monitor startup preflight", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
for (let i = 0; i < 10 && !started.includes("beta"); i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
await waitForStartedAccount(started, "beta");
|
||||
expect(started).toEqual(["alpha", "beta"]);
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("bot info probe timed out"),
|
||||
|
||||
@ -50,6 +50,14 @@ function encryptFeishuPayload(encryptKey: string, payload: Record<string, unknow
|
||||
return Buffer.concat([iv, encrypted]).toString("base64");
|
||||
}
|
||||
|
||||
async function postSignedPayload(url: string, payload: Record<string, unknown>) {
|
||||
return await fetch(url, {
|
||||
method: "POST",
|
||||
headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
stopFeishuMonitor();
|
||||
});
|
||||
@ -143,11 +151,7 @@ describe("Feishu webhook signed-request e2e", () => {
|
||||
monitorFeishuProvider,
|
||||
async (url) => {
|
||||
const payload = { type: "url_verification", challenge: "challenge-token" };
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const response = await postSignedPayload(url, payload);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({ challenge: "challenge-token" });
|
||||
@ -172,11 +176,7 @@ describe("Feishu webhook signed-request e2e", () => {
|
||||
header: { event_type: "unknown.event" },
|
||||
event: {},
|
||||
};
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const response = await postSignedPayload(url, payload);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toContain("no unknown.event event handle");
|
||||
@ -202,11 +202,7 @@ describe("Feishu webhook signed-request e2e", () => {
|
||||
challenge: "encrypted-challenge-token",
|
||||
}),
|
||||
};
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const response = await postSignedPayload(url, payload);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
|
||||
@ -29,12 +29,16 @@ vi.mock("./runtime.js", () => ({
|
||||
import { feishuOutbound } from "./outbound.js";
|
||||
const sendText = feishuOutbound.sendText!;
|
||||
|
||||
function resetOutboundMocks() {
|
||||
vi.clearAllMocks();
|
||||
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
|
||||
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
|
||||
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
|
||||
}
|
||||
|
||||
describe("feishuOutbound.sendText local-image auto-convert", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
|
||||
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
|
||||
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
|
||||
resetOutboundMocks();
|
||||
});
|
||||
|
||||
async function createTmpImage(ext = ".png"): Promise<{ dir: string; file: string }> {
|
||||
@ -181,10 +185,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
|
||||
|
||||
describe("feishuOutbound.sendText replyToId forwarding", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
|
||||
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
|
||||
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
|
||||
resetOutboundMocks();
|
||||
});
|
||||
|
||||
it("forwards replyToId as replyToMessageId to sendMessageFeishu", async () => {
|
||||
@ -249,10 +250,7 @@ describe("feishuOutbound.sendText replyToId forwarding", () => {
|
||||
|
||||
describe("feishuOutbound.sendMedia replyToId forwarding", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
|
||||
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
|
||||
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
|
||||
resetOutboundMocks();
|
||||
});
|
||||
|
||||
it("forwards replyToId to sendMediaFeishu", async () => {
|
||||
@ -292,10 +290,7 @@ describe("feishuOutbound.sendMedia replyToId forwarding", () => {
|
||||
|
||||
describe("feishuOutbound.sendMedia renderMode", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
|
||||
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
|
||||
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
|
||||
resetOutboundMocks();
|
||||
});
|
||||
|
||||
it("uses markdown cards for captions when renderMode=card", async () => {
|
||||
|
||||
@ -25,40 +25,27 @@ vi.mock("./typing.js", () => ({
|
||||
addTypingIndicator: addTypingIndicatorMock,
|
||||
removeTypingIndicator: removeTypingIndicatorMock,
|
||||
}));
|
||||
vi.mock("./streaming-card.js", () => ({
|
||||
mergeStreamingText: (previousText: string | undefined, nextText: string | undefined) => {
|
||||
const previous = typeof previousText === "string" ? previousText : "";
|
||||
const next = typeof nextText === "string" ? nextText : "";
|
||||
if (!next) {
|
||||
return previous;
|
||||
}
|
||||
if (!previous || next === previous) {
|
||||
return next;
|
||||
}
|
||||
if (next.startsWith(previous)) {
|
||||
return next;
|
||||
}
|
||||
if (previous.startsWith(next)) {
|
||||
return previous;
|
||||
}
|
||||
return `${previous}${next}`;
|
||||
},
|
||||
FeishuStreamingSession: class {
|
||||
active = false;
|
||||
start = vi.fn(async () => {
|
||||
this.active = true;
|
||||
});
|
||||
update = vi.fn(async () => {});
|
||||
close = vi.fn(async () => {
|
||||
this.active = false;
|
||||
});
|
||||
isActive = vi.fn(() => this.active);
|
||||
vi.mock("./streaming-card.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./streaming-card.js")>("./streaming-card.js");
|
||||
return {
|
||||
mergeStreamingText: actual.mergeStreamingText,
|
||||
FeishuStreamingSession: class {
|
||||
active = false;
|
||||
start = vi.fn(async () => {
|
||||
this.active = true;
|
||||
});
|
||||
update = vi.fn(async () => {});
|
||||
close = vi.fn(async () => {
|
||||
this.active = false;
|
||||
});
|
||||
isActive = vi.fn(() => this.active);
|
||||
|
||||
constructor() {
|
||||
streamingInstances.push(this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
constructor() {
|
||||
streamingInstances.push(this);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
||||
|
||||
|
||||
@ -144,6 +144,13 @@ describe("extractGeminiCliCredentials", () => {
|
||||
}
|
||||
}
|
||||
|
||||
function expectFakeCliCredentials(result: unknown) {
|
||||
expect(result).toEqual({
|
||||
clientId: FAKE_CLIENT_ID,
|
||||
clientSecret: FAKE_CLIENT_SECRET,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
originalPath = process.env.PATH;
|
||||
@ -169,10 +176,7 @@ describe("extractGeminiCliCredentials", () => {
|
||||
clearCredentialsCache();
|
||||
const result = extractGeminiCliCredentials();
|
||||
|
||||
expect(result).toEqual({
|
||||
clientId: FAKE_CLIENT_ID,
|
||||
clientSecret: FAKE_CLIENT_SECRET,
|
||||
});
|
||||
expectFakeCliCredentials(result);
|
||||
});
|
||||
|
||||
it("extracts credentials when PATH entry is an npm global shim", async () => {
|
||||
@ -182,10 +186,7 @@ describe("extractGeminiCliCredentials", () => {
|
||||
clearCredentialsCache();
|
||||
const result = extractGeminiCliCredentials();
|
||||
|
||||
expect(result).toEqual({
|
||||
clientId: FAKE_CLIENT_ID,
|
||||
clientSecret: FAKE_CLIENT_SECRET,
|
||||
});
|
||||
expectFakeCliCredentials(result);
|
||||
});
|
||||
|
||||
it("returns null when oauth2.js cannot be found", async () => {
|
||||
@ -274,16 +275,16 @@ describe("loginGeminiCliOAuth", () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function runRemoteLoginWithCapturedAuthUrl(
|
||||
loginGeminiCliOAuth: (options: {
|
||||
isRemote: boolean;
|
||||
openUrl: () => Promise<void>;
|
||||
log: (msg: string) => void;
|
||||
note: () => Promise<void>;
|
||||
prompt: () => Promise<string>;
|
||||
progress: { update: () => void; stop: () => void };
|
||||
}) => Promise<{ projectId: string }>,
|
||||
) {
|
||||
type LoginGeminiCliOAuthFn = (options: {
|
||||
isRemote: boolean;
|
||||
openUrl: () => Promise<void>;
|
||||
log: (msg: string) => void;
|
||||
note: () => Promise<void>;
|
||||
prompt: () => Promise<string>;
|
||||
progress: { update: () => void; stop: () => void };
|
||||
}) => Promise<{ projectId: string }>;
|
||||
|
||||
async function runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth: LoginGeminiCliOAuthFn) {
|
||||
let authUrl = "";
|
||||
const result = await loginGeminiCliOAuth({
|
||||
isRemote: true,
|
||||
@ -304,6 +305,14 @@ describe("loginGeminiCliOAuth", () => {
|
||||
return { result, authUrl };
|
||||
}
|
||||
|
||||
async function runRemoteLoginExpectingProjectId(
|
||||
loginGeminiCliOAuth: LoginGeminiCliOAuthFn,
|
||||
projectId: string,
|
||||
) {
|
||||
const { result } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth);
|
||||
expect(result.projectId).toBe(projectId);
|
||||
}
|
||||
|
||||
let envSnapshot: Partial<Record<(typeof ENV_KEYS)[number], string>>;
|
||||
beforeEach(() => {
|
||||
envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
|
||||
@ -357,9 +366,7 @@ describe("loginGeminiCliOAuth", () => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { loginGeminiCliOAuth } = await import("./oauth.js");
|
||||
const { result } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth);
|
||||
|
||||
expect(result.projectId).toBe("daily-project");
|
||||
await runRemoteLoginExpectingProjectId(loginGeminiCliOAuth, "daily-project");
|
||||
const loadRequests = requests.filter((request) =>
|
||||
request.url.includes("v1internal:loadCodeAssist"),
|
||||
);
|
||||
@ -414,9 +421,7 @@ describe("loginGeminiCliOAuth", () => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { loginGeminiCliOAuth } = await import("./oauth.js");
|
||||
const { result } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth);
|
||||
|
||||
expect(result.projectId).toBe("env-project");
|
||||
await runRemoteLoginExpectingProjectId(loginGeminiCliOAuth, "env-project");
|
||||
expect(requests.filter((url) => url.includes("v1internal:loadCodeAssist"))).toHaveLength(3);
|
||||
expect(requests.some((url) => url.includes("v1internal:onboardUser"))).toBe(false);
|
||||
});
|
||||
|
||||
@ -347,6 +347,16 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
: [];
|
||||
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies;
|
||||
const sendMediaMessages = async () => {
|
||||
for (const url of mediaUrls) {
|
||||
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
||||
verbose: false,
|
||||
mediaUrl: url,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!shouldSendQuickRepliesInline) {
|
||||
if (lineData.flexMessage) {
|
||||
@ -391,14 +401,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
|
||||
const sendMediaAfterText = !(hasQuickReplies && chunks.length > 0);
|
||||
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && !sendMediaAfterText) {
|
||||
for (const url of mediaUrls) {
|
||||
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
||||
verbose: false,
|
||||
mediaUrl: url,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
await sendMediaMessages();
|
||||
}
|
||||
|
||||
if (chunks.length > 0) {
|
||||
@ -471,14 +474,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
}
|
||||
|
||||
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && sendMediaAfterText) {
|
||||
for (const url of mediaUrls) {
|
||||
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
||||
verbose: false,
|
||||
mediaUrl: url,
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
await sendMediaMessages();
|
||||
}
|
||||
|
||||
if (lastResult) {
|
||||
|
||||
@ -29,6 +29,21 @@ function fakeApi(overrides: any = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function mockEmbeddedRunJson(payload: unknown) {
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
||||
meta: {},
|
||||
payloads: [{ text: JSON.stringify(payload) }],
|
||||
});
|
||||
}
|
||||
|
||||
async function executeEmbeddedRun(input: Record<string, unknown>) {
|
||||
const tool = createLlmTaskTool(fakeApi());
|
||||
await tool.execute("id", input);
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
return (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
|
||||
}
|
||||
|
||||
describe("llm-task tool (json-only)", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
@ -96,42 +111,25 @@ describe("llm-task tool (json-only)", () => {
|
||||
});
|
||||
|
||||
it("passes provider/model overrides to embedded runner", async () => {
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
||||
meta: {},
|
||||
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
||||
mockEmbeddedRunJson({ ok: true });
|
||||
const call = await executeEmbeddedRun({
|
||||
prompt: "x",
|
||||
provider: "anthropic",
|
||||
model: "claude-4-sonnet",
|
||||
});
|
||||
const tool = createLlmTaskTool(fakeApi());
|
||||
await tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" });
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
|
||||
expect(call.provider).toBe("anthropic");
|
||||
expect(call.model).toBe("claude-4-sonnet");
|
||||
});
|
||||
|
||||
it("passes thinking override to embedded runner", async () => {
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
||||
meta: {},
|
||||
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
||||
});
|
||||
const tool = createLlmTaskTool(fakeApi());
|
||||
await tool.execute("id", { prompt: "x", thinking: "high" });
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
|
||||
mockEmbeddedRunJson({ ok: true });
|
||||
const call = await executeEmbeddedRun({ prompt: "x", thinking: "high" });
|
||||
expect(call.thinkLevel).toBe("high");
|
||||
});
|
||||
|
||||
it("normalizes thinking aliases", async () => {
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
||||
meta: {},
|
||||
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
||||
});
|
||||
const tool = createLlmTaskTool(fakeApi());
|
||||
await tool.execute("id", { prompt: "x", thinking: "on" });
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
|
||||
mockEmbeddedRunJson({ ok: true });
|
||||
const call = await executeEmbeddedRun({ prompt: "x", thinking: "on" });
|
||||
expect(call.thinkLevel).toBe("low");
|
||||
});
|
||||
|
||||
@ -150,24 +148,13 @@ describe("llm-task tool (json-only)", () => {
|
||||
});
|
||||
|
||||
it("does not pass thinkLevel when thinking is omitted", async () => {
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
||||
meta: {},
|
||||
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
||||
});
|
||||
const tool = createLlmTaskTool(fakeApi());
|
||||
await tool.execute("id", { prompt: "x" });
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
|
||||
mockEmbeddedRunJson({ ok: true });
|
||||
const call = await executeEmbeddedRun({ prompt: "x" });
|
||||
expect(call.thinkLevel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("enforces allowedModels", async () => {
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
||||
meta: {},
|
||||
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
||||
});
|
||||
mockEmbeddedRunJson({ ok: true });
|
||||
const tool = createLlmTaskTool(
|
||||
fakeApi({ pluginConfig: { allowedModels: ["openai-codex/gpt-5.2"] } }),
|
||||
);
|
||||
@ -177,15 +164,8 @@ describe("llm-task tool (json-only)", () => {
|
||||
});
|
||||
|
||||
it("disables tools for embedded run", async () => {
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
||||
meta: {},
|
||||
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
||||
});
|
||||
const tool = createLlmTaskTool(fakeApi());
|
||||
await tool.execute("id", { prompt: "x" });
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
|
||||
mockEmbeddedRunJson({ ok: true });
|
||||
const call = await executeEmbeddedRun({ prompt: "x" });
|
||||
expect(call.disableTools).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,26 +2,12 @@ import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { matrixPlugin } from "./channel.js";
|
||||
import { setMatrixRuntime } from "./runtime.js";
|
||||
import { createMatrixBotSdkMock } from "./test-mocks.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
vi.mock("@vector-im/matrix-bot-sdk", () => ({
|
||||
ConsoleLogger: class {
|
||||
trace = vi.fn();
|
||||
debug = vi.fn();
|
||||
info = vi.fn();
|
||||
warn = vi.fn();
|
||||
error = vi.fn();
|
||||
},
|
||||
MatrixClient: class {},
|
||||
LogService: {
|
||||
setLogger: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
SimpleFsStorageProvider: class {},
|
||||
RustSdkCryptoStorageProvider: class {},
|
||||
}));
|
||||
vi.mock("@vector-im/matrix-bot-sdk", () =>
|
||||
createMatrixBotSdkMock({ includeVerboseLogService: true }),
|
||||
);
|
||||
|
||||
describe("matrix directory", () => {
|
||||
const runtimeEnv: RuntimeEnv = {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setMatrixRuntime } from "../runtime.js";
|
||||
import { createMatrixBotSdkMock } from "../test-mocks.js";
|
||||
|
||||
vi.mock("music-metadata", () => ({
|
||||
// `resolveMediaDurationMs` lazily imports `music-metadata`; in tests we don't
|
||||
@ -8,21 +9,13 @@ vi.mock("music-metadata", () => ({
|
||||
parseBuffer: vi.fn().mockResolvedValue({ format: {} }),
|
||||
}));
|
||||
|
||||
vi.mock("@vector-im/matrix-bot-sdk", () => ({
|
||||
ConsoleLogger: class {
|
||||
trace = vi.fn();
|
||||
debug = vi.fn();
|
||||
info = vi.fn();
|
||||
warn = vi.fn();
|
||||
error = vi.fn();
|
||||
},
|
||||
LogService: {
|
||||
setLogger: vi.fn(),
|
||||
},
|
||||
MatrixClient: vi.fn(),
|
||||
SimpleFsStorageProvider: vi.fn(),
|
||||
RustSdkCryptoStorageProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@vector-im/matrix-bot-sdk", () =>
|
||||
createMatrixBotSdkMock({
|
||||
matrixClient: vi.fn(),
|
||||
simpleFsStorageProvider: vi.fn(),
|
||||
rustSdkCryptoStorageProvider: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("./send-queue.js", () => ({
|
||||
enqueueSend: async <T>(_roomId: string, fn: () => Promise<T>) => await fn(),
|
||||
|
||||
@ -8,6 +8,15 @@ vi.mock("./directory-live.js", () => ({
|
||||
listMatrixDirectoryGroupsLive: vi.fn(),
|
||||
}));
|
||||
|
||||
async function resolveUserTarget(input = "Alice") {
|
||||
const [result] = await resolveMatrixTargets({
|
||||
cfg: {},
|
||||
inputs: [input],
|
||||
kind: "user",
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("resolveMatrixTargets (users)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(listMatrixDirectoryPeersLive).mockReset();
|
||||
@ -20,11 +29,7 @@ describe("resolveMatrixTargets (users)", () => {
|
||||
];
|
||||
vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches);
|
||||
|
||||
const [result] = await resolveMatrixTargets({
|
||||
cfg: {},
|
||||
inputs: ["Alice"],
|
||||
kind: "user",
|
||||
});
|
||||
const result = await resolveUserTarget();
|
||||
|
||||
expect(result?.resolved).toBe(true);
|
||||
expect(result?.id).toBe("@alice:example.org");
|
||||
@ -37,11 +42,7 @@ describe("resolveMatrixTargets (users)", () => {
|
||||
];
|
||||
vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches);
|
||||
|
||||
const [result] = await resolveMatrixTargets({
|
||||
cfg: {},
|
||||
inputs: ["Alice"],
|
||||
kind: "user",
|
||||
});
|
||||
const result = await resolveUserTarget();
|
||||
|
||||
expect(result?.resolved).toBe(false);
|
||||
expect(result?.note).toMatch(/use full Matrix ID/i);
|
||||
|
||||
33
extensions/matrix/src/test-mocks.ts
Normal file
33
extensions/matrix/src/test-mocks.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
type MatrixBotSdkMockParams = {
|
||||
matrixClient?: unknown;
|
||||
simpleFsStorageProvider?: unknown;
|
||||
rustSdkCryptoStorageProvider?: unknown;
|
||||
includeVerboseLogService?: boolean;
|
||||
};
|
||||
|
||||
export function createMatrixBotSdkMock(params: MatrixBotSdkMockParams = {}) {
|
||||
return {
|
||||
ConsoleLogger: class {
|
||||
trace = vi.fn();
|
||||
debug = vi.fn();
|
||||
info = vi.fn();
|
||||
warn = vi.fn();
|
||||
error = vi.fn();
|
||||
},
|
||||
MatrixClient: params.matrixClient ?? class {},
|
||||
LogService: {
|
||||
setLogger: vi.fn(),
|
||||
...(params.includeVerboseLogService
|
||||
? {
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
SimpleFsStorageProvider: params.simpleFsStorageProvider ?? class {},
|
||||
RustSdkCryptoStorageProvider: params.rustSdkCryptoStorageProvider ?? class {},
|
||||
};
|
||||
}
|
||||
@ -18,12 +18,12 @@ const HAS_OPENAI_KEY = Boolean(process.env.OPENAI_API_KEY);
|
||||
const liveEnabled = HAS_OPENAI_KEY && process.env.OPENCLAW_LIVE_TEST === "1";
|
||||
const describeLive = liveEnabled ? describe : describe.skip;
|
||||
|
||||
describe("memory plugin e2e", () => {
|
||||
let tmpDir: string;
|
||||
let dbPath: string;
|
||||
function installTmpDirHarness(params: { prefix: string }) {
|
||||
let tmpDir = "";
|
||||
let dbPath = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-test-"));
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), params.prefix));
|
||||
dbPath = path.join(tmpDir, "lancedb");
|
||||
});
|
||||
|
||||
@ -33,6 +33,27 @@ describe("memory plugin e2e", () => {
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
getTmpDir: () => tmpDir,
|
||||
getDbPath: () => dbPath,
|
||||
};
|
||||
}
|
||||
|
||||
describe("memory plugin e2e", () => {
|
||||
const { getDbPath } = installTmpDirHarness({ prefix: "openclaw-memory-test-" });
|
||||
|
||||
async function parseConfig(overrides: Record<string, unknown> = {}) {
|
||||
const { default: memoryPlugin } = await import("./index.js");
|
||||
return memoryPlugin.configSchema?.parse?.({
|
||||
embedding: {
|
||||
apiKey: OPENAI_API_KEY,
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
dbPath: getDbPath(),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test("memory plugin registers and initializes correctly", async () => {
|
||||
// Dynamic import to avoid loading LanceDB when not testing
|
||||
const { default: memoryPlugin } = await import("./index.js");
|
||||
@ -46,21 +67,14 @@ describe("memory plugin e2e", () => {
|
||||
});
|
||||
|
||||
test("config schema parses valid config", async () => {
|
||||
const { default: memoryPlugin } = await import("./index.js");
|
||||
|
||||
const config = memoryPlugin.configSchema?.parse?.({
|
||||
embedding: {
|
||||
apiKey: OPENAI_API_KEY,
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
dbPath,
|
||||
const config = await parseConfig({
|
||||
autoCapture: true,
|
||||
autoRecall: true,
|
||||
});
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config?.embedding?.apiKey).toBe(OPENAI_API_KEY);
|
||||
expect(config?.dbPath).toBe(dbPath);
|
||||
expect(config?.dbPath).toBe(getDbPath());
|
||||
expect(config?.captureMaxChars).toBe(500);
|
||||
});
|
||||
|
||||
@ -74,7 +88,7 @@ describe("memory plugin e2e", () => {
|
||||
embedding: {
|
||||
apiKey: "${TEST_MEMORY_API_KEY}",
|
||||
},
|
||||
dbPath,
|
||||
dbPath: getDbPath(),
|
||||
});
|
||||
|
||||
expect(config?.embedding?.apiKey).toBe("test-key-123");
|
||||
@ -88,7 +102,7 @@ describe("memory plugin e2e", () => {
|
||||
expect(() => {
|
||||
memoryPlugin.configSchema?.parse?.({
|
||||
embedding: {},
|
||||
dbPath,
|
||||
dbPath: getDbPath(),
|
||||
});
|
||||
}).toThrow("embedding.apiKey is required");
|
||||
});
|
||||
@ -99,21 +113,14 @@ describe("memory plugin e2e", () => {
|
||||
expect(() => {
|
||||
memoryPlugin.configSchema?.parse?.({
|
||||
embedding: { apiKey: OPENAI_API_KEY },
|
||||
dbPath,
|
||||
dbPath: getDbPath(),
|
||||
captureMaxChars: 99,
|
||||
});
|
||||
}).toThrow("captureMaxChars must be between 100 and 10000");
|
||||
});
|
||||
|
||||
test("config schema accepts captureMaxChars override", async () => {
|
||||
const { default: memoryPlugin } = await import("./index.js");
|
||||
|
||||
const config = memoryPlugin.configSchema?.parse?.({
|
||||
embedding: {
|
||||
apiKey: OPENAI_API_KEY,
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
dbPath,
|
||||
const config = await parseConfig({
|
||||
captureMaxChars: 1800,
|
||||
});
|
||||
|
||||
@ -121,15 +128,7 @@ describe("memory plugin e2e", () => {
|
||||
});
|
||||
|
||||
test("config schema keeps autoCapture disabled by default", async () => {
|
||||
const { default: memoryPlugin } = await import("./index.js");
|
||||
|
||||
const config = memoryPlugin.configSchema?.parse?.({
|
||||
embedding: {
|
||||
apiKey: OPENAI_API_KEY,
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
dbPath,
|
||||
});
|
||||
const config = await parseConfig();
|
||||
|
||||
expect(config?.autoCapture).toBe(false);
|
||||
expect(config?.autoRecall).toBe(true);
|
||||
@ -176,7 +175,7 @@ describe("memory plugin e2e", () => {
|
||||
model: "text-embedding-3-small",
|
||||
dimensions: 1024,
|
||||
},
|
||||
dbPath,
|
||||
dbPath: getDbPath(),
|
||||
autoCapture: false,
|
||||
autoRecall: false,
|
||||
},
|
||||
@ -279,19 +278,7 @@ describe("memory plugin e2e", () => {
|
||||
|
||||
// Live tests that require OpenAI API key and actually use LanceDB
|
||||
describeLive("memory plugin live tests", () => {
|
||||
let tmpDir: string;
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-live-"));
|
||||
dbPath = path.join(tmpDir, "lancedb");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tmpDir) {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
const { getDbPath } = installTmpDirHarness({ prefix: "openclaw-memory-live-" });
|
||||
|
||||
test("memory tools work end-to-end", async () => {
|
||||
const { default: memoryPlugin } = await import("./index.js");
|
||||
@ -318,7 +305,7 @@ describeLive("memory plugin live tests", () => {
|
||||
apiKey: liveApiKey,
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
dbPath,
|
||||
dbPath: getDbPath(),
|
||||
autoCapture: false,
|
||||
autoRecall: false,
|
||||
},
|
||||
|
||||
@ -88,14 +88,17 @@ function isUrlAllowedBySsrfPolicy(url: string, policy?: SsrFPolicy): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => {
|
||||
async function fetchRemoteMediaWithRedirects(
|
||||
params: RemoteMediaFetchParams,
|
||||
requestInit?: RequestInit,
|
||||
) {
|
||||
const fetchFn = params.fetchImpl ?? fetch;
|
||||
let currentUrl = params.url;
|
||||
for (let i = 0; i <= MAX_REDIRECT_HOPS; i += 1) {
|
||||
if (!isUrlAllowedBySsrfPolicy(currentUrl, params.ssrfPolicy)) {
|
||||
throw new Error(`Blocked hostname (not in allowlist): ${currentUrl}`);
|
||||
}
|
||||
const res = await fetchFn(currentUrl, { redirect: "manual" });
|
||||
const res = await fetchFn(currentUrl, { redirect: "manual", ...requestInit });
|
||||
if (REDIRECT_STATUS_CODES.includes(res.status)) {
|
||||
const location = res.headers.get("location");
|
||||
if (!location) {
|
||||
@ -107,6 +110,10 @@ const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => {
|
||||
return readRemoteMediaResponse(res, params);
|
||||
}
|
||||
throw new Error("too many redirects");
|
||||
}
|
||||
|
||||
const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => {
|
||||
return await fetchRemoteMediaWithRedirects(params);
|
||||
});
|
||||
|
||||
const runtimeStub: PluginRuntime = createPluginRuntimeMock({
|
||||
@ -720,24 +727,9 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
fetchRemoteMediaMock.mockImplementationOnce(async (params) => {
|
||||
const fetchFn = params.fetchImpl ?? fetch;
|
||||
let currentUrl = params.url;
|
||||
for (let i = 0; i < MAX_REDIRECT_HOPS; i += 1) {
|
||||
const res = await fetchFn(currentUrl, {
|
||||
redirect: "manual",
|
||||
dispatcher: {},
|
||||
} as RequestInit);
|
||||
if (REDIRECT_STATUS_CODES.includes(res.status)) {
|
||||
const location = res.headers.get("location");
|
||||
if (!location) {
|
||||
throw new Error("redirect missing location");
|
||||
}
|
||||
currentUrl = new URL(location, currentUrl).toString();
|
||||
continue;
|
||||
}
|
||||
return readRemoteMediaResponse(res, params);
|
||||
}
|
||||
throw new Error("too many redirects");
|
||||
return await fetchRemoteMediaWithRedirects(params, {
|
||||
dispatcher: {},
|
||||
} as RequestInit);
|
||||
});
|
||||
|
||||
const media = await downloadAttachmentsWithFetch(
|
||||
|
||||
@ -139,6 +139,22 @@ describe("msteams messenger", () => {
|
||||
});
|
||||
|
||||
describe("sendMSTeamsMessages", () => {
|
||||
function createRevokedThreadContext(params?: { failAfterAttempt?: number; sent?: string[] }) {
|
||||
let attempt = 0;
|
||||
return {
|
||||
sendActivity: async (activity: unknown) => {
|
||||
const { text } = activity as { text?: string };
|
||||
const content = text ?? "";
|
||||
attempt += 1;
|
||||
if (params?.failAfterAttempt && attempt < params.failAfterAttempt) {
|
||||
params.sent?.push(content);
|
||||
return { id: `id:${content}` };
|
||||
}
|
||||
throw new TypeError(REVOCATION_ERROR);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseRef: StoredConversationReference = {
|
||||
activityId: "activity123",
|
||||
user: { id: "user123", name: "User" },
|
||||
@ -305,13 +321,7 @@ describe("msteams messenger", () => {
|
||||
|
||||
it("falls back to proactive messaging when thread context is revoked", async () => {
|
||||
const proactiveSent: string[] = [];
|
||||
|
||||
const ctx = {
|
||||
sendActivity: async () => {
|
||||
throw new TypeError(REVOCATION_ERROR);
|
||||
},
|
||||
};
|
||||
|
||||
const ctx = createRevokedThreadContext();
|
||||
const adapter = createFallbackAdapter(proactiveSent);
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
@ -331,21 +341,7 @@ describe("msteams messenger", () => {
|
||||
it("falls back only for remaining thread messages after context revocation", async () => {
|
||||
const threadSent: string[] = [];
|
||||
const proactiveSent: string[] = [];
|
||||
let attempt = 0;
|
||||
|
||||
const ctx = {
|
||||
sendActivity: async (activity: unknown) => {
|
||||
const { text } = activity as { text?: string };
|
||||
const content = text ?? "";
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
threadSent.push(content);
|
||||
return { id: `id:${content}` };
|
||||
}
|
||||
throw new TypeError(REVOCATION_ERROR);
|
||||
},
|
||||
};
|
||||
|
||||
const ctx = createRevokedThreadContext({ failAfterAttempt: 2, sent: threadSent });
|
||||
const adapter = createFallbackAdapter(proactiveSent);
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
|
||||
@ -2,11 +2,9 @@ import {
|
||||
buildSglangProvider,
|
||||
configureOpenAICompatibleSelfHostedProviderNonInteractive,
|
||||
emptyPluginConfigSchema,
|
||||
promptAndConfigureOpenAICompatibleSelfHostedProvider,
|
||||
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth,
|
||||
type OpenClawPluginApi,
|
||||
type ProviderAuthContext,
|
||||
type ProviderAuthMethodNonInteractiveContext,
|
||||
type ProviderAuthResult,
|
||||
type ProviderDiscoveryContext,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
|
||||
@ -30,8 +28,8 @@ const sglangPlugin = {
|
||||
label: "SGLang",
|
||||
hint: "Fast self-hosted OpenAI-compatible server",
|
||||
kind: "custom",
|
||||
run: async (ctx: ProviderAuthContext): Promise<ProviderAuthResult> => {
|
||||
const result = await promptAndConfigureOpenAICompatibleSelfHostedProvider({
|
||||
run: (ctx) =>
|
||||
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({
|
||||
cfg: ctx.config,
|
||||
prompter: ctx.prompter,
|
||||
providerId: PROVIDER_ID,
|
||||
@ -39,18 +37,7 @@ const sglangPlugin = {
|
||||
defaultBaseUrl: DEFAULT_BASE_URL,
|
||||
defaultApiKeyEnvVar: "SGLANG_API_KEY",
|
||||
modelPlaceholder: "Qwen/Qwen3-8B",
|
||||
});
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId: result.profileId,
|
||||
credential: result.credential,
|
||||
},
|
||||
],
|
||||
configPatch: result.config,
|
||||
defaultModel: result.modelRef,
|
||||
};
|
||||
},
|
||||
}),
|
||||
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
|
||||
configureOpenAICompatibleSelfHostedProviderNonInteractive({
|
||||
ctx,
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
registerPluginHttpRouteMock,
|
||||
} from "./channel.test-mocks.js";
|
||||
import { makeFormBody, makeReq, makeRes } from "./test-http-utils.js";
|
||||
|
||||
type RegisteredRoute = {
|
||||
@ -8,41 +12,6 @@ type RegisteredRoute = {
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
||||
};
|
||||
|
||||
const registerPluginHttpRouteMock = vi.fn<(params: RegisteredRoute) => () => void>(() => vi.fn());
|
||||
const dispatchReplyWithBufferedBlockDispatcher = vi.fn().mockResolvedValue({ counts: {} });
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/synology-chat", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/synology-chat")>();
|
||||
return {
|
||||
...actual,
|
||||
DEFAULT_ACCOUNT_ID: "default",
|
||||
setAccountEnabledInConfigSection: vi.fn((_opts: any) => ({})),
|
||||
registerPluginHttpRoute: registerPluginHttpRouteMock,
|
||||
buildChannelConfigSchema: vi.fn((schema: any) => ({ schema })),
|
||||
createFixedWindowRateLimiter: vi.fn(() => ({
|
||||
isRateLimited: vi.fn(() => false),
|
||||
size: vi.fn(() => 0),
|
||||
clear: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getSynologyRuntime: vi.fn(() => ({
|
||||
config: { loadConfig: vi.fn().mockResolvedValue({}) },
|
||||
channel: {
|
||||
reply: {
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
},
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
sendMessage: vi.fn().mockResolvedValue(true),
|
||||
sendFileUrl: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const { createSynologyChatPlugin } = await import("./channel.js");
|
||||
describe("Synology channel wiring integration", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
73
extensions/synology-chat/src/channel.test-mocks.ts
Normal file
73
extensions/synology-chat/src/channel.test-mocks.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { vi } from "vitest";
|
||||
|
||||
export type RegisteredRoute = {
|
||||
path: string;
|
||||
accountId: string;
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
||||
};
|
||||
|
||||
export const registerPluginHttpRouteMock = vi.fn<(params: RegisteredRoute) => () => void>(() =>
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
export const dispatchReplyWithBufferedBlockDispatcher = vi.fn().mockResolvedValue({ counts: {} });
|
||||
|
||||
async function readRequestBodyWithLimitForTest(req: IncomingMessage): Promise<string> {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/synology-chat", () => ({
|
||||
DEFAULT_ACCOUNT_ID: "default",
|
||||
setAccountEnabledInConfigSection: vi.fn((_opts: unknown) => ({})),
|
||||
registerPluginHttpRoute: registerPluginHttpRouteMock,
|
||||
buildChannelConfigSchema: vi.fn((schema: unknown) => ({ schema })),
|
||||
readRequestBodyWithLimit: vi.fn(readRequestBodyWithLimitForTest),
|
||||
isRequestBodyLimitError: vi.fn(() => false),
|
||||
requestBodyErrorToText: vi.fn(() => "Request body too large"),
|
||||
createFixedWindowRateLimiter: vi.fn(() => ({
|
||||
isRateLimited: vi.fn(() => false),
|
||||
size: vi.fn(() => 0),
|
||||
clear: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
sendMessage: vi.fn().mockResolvedValue(true),
|
||||
sendFileUrl: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getSynologyRuntime: vi.fn(() => ({
|
||||
config: { loadConfig: vi.fn().mockResolvedValue({}) },
|
||||
channel: {
|
||||
reply: {
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
},
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
export function makeSecurityAccount(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
token: "t",
|
||||
incomingUrl: "https://nas/incoming",
|
||||
nasHost: "h",
|
||||
webhookPath: "/w",
|
||||
dmPolicy: "allowlist" as const,
|
||||
allowedUserIds: [],
|
||||
rateLimitPerMinute: 30,
|
||||
botName: "Bot",
|
||||
allowInsecureSsl: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@ -1,40 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock("openclaw/plugin-sdk/synology-chat", () => ({
|
||||
DEFAULT_ACCOUNT_ID: "default",
|
||||
setAccountEnabledInConfigSection: vi.fn((_opts: any) => ({})),
|
||||
registerPluginHttpRoute: vi.fn(() => vi.fn()),
|
||||
buildChannelConfigSchema: vi.fn((schema: any) => ({ schema })),
|
||||
createFixedWindowRateLimiter: vi.fn(() => ({
|
||||
isRateLimited: vi.fn(() => false),
|
||||
size: vi.fn(() => 0),
|
||||
clear: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
sendMessage: vi.fn().mockResolvedValue(true),
|
||||
sendFileUrl: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { makeSecurityAccount, registerPluginHttpRouteMock } from "./channel.test-mocks.js";
|
||||
|
||||
vi.mock("./webhook-handler.js", () => ({
|
||||
createWebhookHandler: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getSynologyRuntime: vi.fn(() => ({
|
||||
config: { loadConfig: vi.fn().mockResolvedValue({}) },
|
||||
channel: {
|
||||
reply: {
|
||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn().mockResolvedValue({
|
||||
counts: {},
|
||||
}),
|
||||
},
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("zod", () => ({
|
||||
z: {
|
||||
object: vi.fn(() => ({
|
||||
@ -44,24 +14,6 @@ vi.mock("zod", () => ({
|
||||
}));
|
||||
|
||||
const { createSynologyChatPlugin } = await import("./channel.js");
|
||||
const { registerPluginHttpRoute } = await import("openclaw/plugin-sdk/synology-chat");
|
||||
|
||||
function makeSecurityAccount(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
token: "t",
|
||||
incomingUrl: "https://nas/incoming",
|
||||
nasHost: "h",
|
||||
webhookPath: "/w",
|
||||
dmPolicy: "allowlist" as const,
|
||||
allowedUserIds: [],
|
||||
rateLimitPerMinute: 30,
|
||||
botName: "Bot",
|
||||
allowInsecureSsl: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("createSynologyChatPlugin", () => {
|
||||
it("returns a plugin object with all required sections", () => {
|
||||
@ -321,7 +273,7 @@ describe("createSynologyChatPlugin", () => {
|
||||
});
|
||||
|
||||
it("startAccount refuses allowlist accounts with empty allowedUserIds", async () => {
|
||||
const registerMock = vi.mocked(registerPluginHttpRoute);
|
||||
const registerMock = registerPluginHttpRouteMock;
|
||||
registerMock.mockClear();
|
||||
const plugin = createSynologyChatPlugin();
|
||||
const { ctx, abortController } = makeStartAccountCtx({
|
||||
@ -341,7 +293,7 @@ describe("createSynologyChatPlugin", () => {
|
||||
it("deregisters stale route before re-registering same account/path", async () => {
|
||||
const unregisterFirst = vi.fn();
|
||||
const unregisterSecond = vi.fn();
|
||||
const registerMock = vi.mocked(registerPluginHttpRoute);
|
||||
const registerMock = registerPluginHttpRouteMock;
|
||||
registerMock.mockReturnValueOnce(unregisterFirst).mockReturnValueOnce(unregisterSecond);
|
||||
|
||||
const plugin = createSynologyChatPlugin();
|
||||
|
||||
@ -78,6 +78,61 @@ function formatDuplicateTelegramTokenReason(params: {
|
||||
);
|
||||
}
|
||||
|
||||
type TelegramSendFn = ReturnType<
|
||||
typeof getTelegramRuntime
|
||||
>["channel"]["telegram"]["sendMessageTelegram"];
|
||||
type TelegramSendOptions = NonNullable<Parameters<TelegramSendFn>[2]>;
|
||||
|
||||
function buildTelegramSendOptions(params: {
|
||||
cfg: OpenClawConfig;
|
||||
mediaUrl?: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
accountId?: string;
|
||||
replyToId?: string;
|
||||
threadId?: string;
|
||||
silent?: boolean;
|
||||
}): TelegramSendOptions {
|
||||
return {
|
||||
verbose: false,
|
||||
cfg: params.cfg,
|
||||
...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}),
|
||||
...(params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}),
|
||||
messageThreadId: parseTelegramThreadId(params.threadId),
|
||||
replyToMessageId: parseTelegramReplyToMessageId(params.replyToId),
|
||||
accountId: params.accountId ?? undefined,
|
||||
silent: params.silent ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendTelegramOutbound(params: {
|
||||
cfg: OpenClawConfig;
|
||||
to: string;
|
||||
text: string;
|
||||
mediaUrl?: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
accountId?: string;
|
||||
deps?: { sendTelegram?: TelegramSendFn };
|
||||
replyToId?: string;
|
||||
threadId?: string;
|
||||
silent?: boolean;
|
||||
}) {
|
||||
const send =
|
||||
params.deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
|
||||
return await send(
|
||||
params.to,
|
||||
params.text,
|
||||
buildTelegramSendOptions({
|
||||
cfg: params.cfg,
|
||||
mediaUrl: params.mediaUrl,
|
||||
mediaLocalRoots: params.mediaLocalRoots,
|
||||
accountId: params.accountId,
|
||||
replyToId: params.replyToId,
|
||||
threadId: params.threadId,
|
||||
silent: params.silent,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: (ctx) =>
|
||||
getTelegramRuntime().channel.telegram.messageActions?.listActions?.(ctx) ?? [],
|
||||
@ -327,35 +382,31 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
silent,
|
||||
}) => {
|
||||
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
|
||||
const replyToMessageId = parseTelegramReplyToMessageId(replyToId);
|
||||
const messageThreadId = parseTelegramThreadId(threadId);
|
||||
const result = await sendTelegramPayloadMessages({
|
||||
send,
|
||||
to,
|
||||
payload,
|
||||
baseOpts: {
|
||||
verbose: false,
|
||||
baseOpts: buildTelegramSendOptions({
|
||||
cfg,
|
||||
mediaLocalRoots,
|
||||
messageThreadId,
|
||||
replyToMessageId,
|
||||
accountId: accountId ?? undefined,
|
||||
silent: silent ?? undefined,
|
||||
},
|
||||
accountId,
|
||||
replyToId,
|
||||
threadId,
|
||||
silent,
|
||||
}),
|
||||
});
|
||||
return { channel: "telegram", ...result };
|
||||
},
|
||||
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => {
|
||||
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
|
||||
const replyToMessageId = parseTelegramReplyToMessageId(replyToId);
|
||||
const messageThreadId = parseTelegramThreadId(threadId);
|
||||
const result = await send(to, text, {
|
||||
verbose: false,
|
||||
const result = await sendTelegramOutbound({
|
||||
cfg,
|
||||
messageThreadId,
|
||||
replyToMessageId,
|
||||
accountId: accountId ?? undefined,
|
||||
silent: silent ?? undefined,
|
||||
to,
|
||||
text,
|
||||
accountId,
|
||||
deps,
|
||||
replyToId,
|
||||
threadId,
|
||||
silent,
|
||||
});
|
||||
return { channel: "telegram", ...result };
|
||||
},
|
||||
@ -371,18 +422,17 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
threadId,
|
||||
silent,
|
||||
}) => {
|
||||
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
|
||||
const replyToMessageId = parseTelegramReplyToMessageId(replyToId);
|
||||
const messageThreadId = parseTelegramThreadId(threadId);
|
||||
const result = await send(to, text, {
|
||||
verbose: false,
|
||||
const result = await sendTelegramOutbound({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
messageThreadId,
|
||||
replyToMessageId,
|
||||
accountId: accountId ?? undefined,
|
||||
silent: silent ?? undefined,
|
||||
accountId,
|
||||
deps,
|
||||
replyToId,
|
||||
threadId,
|
||||
silent,
|
||||
});
|
||||
return { channel: "telegram", ...result };
|
||||
},
|
||||
|
||||
@ -51,6 +51,13 @@ describe("thread-ownership plugin", () => {
|
||||
register(api as any);
|
||||
});
|
||||
|
||||
async function sendSlackThreadMessage() {
|
||||
return await hooks.message_sending(
|
||||
{ content: "hello", metadata: { threadTs: "1234.5678", channelId: "C123" }, to: "C123" },
|
||||
{ channelId: "slack", conversationId: "C123" },
|
||||
);
|
||||
}
|
||||
|
||||
it("allows non-slack channels", async () => {
|
||||
const result = await hooks.message_sending(
|
||||
{ content: "hello", metadata: { threadTs: "1234.5678", channelId: "C123" }, to: "C123" },
|
||||
@ -76,10 +83,7 @@ describe("thread-ownership plugin", () => {
|
||||
new Response(JSON.stringify({ owner: "test-agent" }), { status: 200 }),
|
||||
);
|
||||
|
||||
const result = await hooks.message_sending(
|
||||
{ content: "hello", metadata: { threadTs: "1234.5678", channelId: "C123" }, to: "C123" },
|
||||
{ channelId: "slack", conversationId: "C123" },
|
||||
);
|
||||
const result = await sendSlackThreadMessage();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
@ -96,10 +100,7 @@ describe("thread-ownership plugin", () => {
|
||||
new Response(JSON.stringify({ owner: "other-agent" }), { status: 409 }),
|
||||
);
|
||||
|
||||
const result = await hooks.message_sending(
|
||||
{ content: "hello", metadata: { threadTs: "1234.5678", channelId: "C123" }, to: "C123" },
|
||||
{ channelId: "slack", conversationId: "C123" },
|
||||
);
|
||||
const result = await sendSlackThreadMessage();
|
||||
|
||||
expect(result).toEqual({ cancel: true });
|
||||
expect(api.logger.info).toHaveBeenCalledWith(expect.stringContaining("cancelled send"));
|
||||
@ -108,10 +109,7 @@ describe("thread-ownership plugin", () => {
|
||||
it("fails open on network error", async () => {
|
||||
vi.mocked(globalThis.fetch).mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
|
||||
const result = await hooks.message_sending(
|
||||
{ content: "hello", metadata: { threadTs: "1234.5678", channelId: "C123" }, to: "C123" },
|
||||
{ channelId: "slack", conversationId: "C123" },
|
||||
);
|
||||
const result = await sendSlackThreadMessage();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(api.logger.warn).toHaveBeenCalledWith(
|
||||
|
||||
@ -153,6 +153,48 @@ function applyTlonSetupConfig(params: {
|
||||
};
|
||||
}
|
||||
|
||||
type ResolvedTlonAccount = ReturnType<typeof resolveTlonAccount>;
|
||||
|
||||
function resolveOutboundContext(params: { cfg: OpenClawConfig; accountId?: string; to: string }) {
|
||||
const account = resolveTlonAccount(params.cfg, params.accountId ?? undefined);
|
||||
if (!account.configured || !account.ship || !account.url || !account.code) {
|
||||
throw new Error("Tlon account not configured");
|
||||
}
|
||||
|
||||
const parsed = parseTlonTarget(params.to);
|
||||
if (!parsed) {
|
||||
throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`);
|
||||
}
|
||||
|
||||
return { account, parsed };
|
||||
}
|
||||
|
||||
function resolveReplyId(replyToId?: string, threadId?: string) {
|
||||
return (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined;
|
||||
}
|
||||
|
||||
async function withHttpPokeAccountApi<T>(
|
||||
account: ResolvedTlonAccount & { ship: string; url: string; code: string },
|
||||
run: (api: Awaited<ReturnType<typeof createHttpPokeApi>>) => Promise<T>,
|
||||
) {
|
||||
const api = await createHttpPokeApi({
|
||||
url: account.url,
|
||||
ship: account.ship,
|
||||
code: account.code,
|
||||
allowPrivateNetwork: account.allowPrivateNetwork ?? undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
return await run(api);
|
||||
} finally {
|
||||
try {
|
||||
await api.delete();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tlonOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
textChunkLimit: 10000,
|
||||
@ -170,25 +212,8 @@ const tlonOutbound: ChannelOutboundAdapter = {
|
||||
return { ok: true, to: parsed.nest };
|
||||
},
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
|
||||
const account = resolveTlonAccount(cfg, accountId ?? undefined);
|
||||
if (!account.configured || !account.ship || !account.url || !account.code) {
|
||||
throw new Error("Tlon account not configured");
|
||||
}
|
||||
|
||||
const parsed = parseTlonTarget(to);
|
||||
if (!parsed) {
|
||||
throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`);
|
||||
}
|
||||
|
||||
// Use HTTP-only poke (no EventSource) to avoid conflicts with monitor's SSE connection
|
||||
const api = await createHttpPokeApi({
|
||||
url: account.url,
|
||||
ship: account.ship,
|
||||
code: account.code,
|
||||
allowPrivateNetwork: account.allowPrivateNetwork ?? undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
const { account, parsed } = resolveOutboundContext({ cfg, accountId, to });
|
||||
return withHttpPokeAccountApi(account, async (api) => {
|
||||
const fromShip = normalizeShip(account.ship);
|
||||
if (parsed.kind === "dm") {
|
||||
return await sendDm({
|
||||
@ -198,33 +223,18 @@ const tlonOutbound: ChannelOutboundAdapter = {
|
||||
text,
|
||||
});
|
||||
}
|
||||
const replyId = (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined;
|
||||
return await sendGroupMessage({
|
||||
api,
|
||||
fromShip,
|
||||
hostShip: parsed.hostShip,
|
||||
channelName: parsed.channelName,
|
||||
text,
|
||||
replyToId: replyId,
|
||||
replyToId: resolveReplyId(replyToId, threadId),
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
await api.delete();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => {
|
||||
const account = resolveTlonAccount(cfg, accountId ?? undefined);
|
||||
if (!account.configured || !account.ship || !account.url || !account.code) {
|
||||
throw new Error("Tlon account not configured");
|
||||
}
|
||||
|
||||
const parsed = parseTlonTarget(to);
|
||||
if (!parsed) {
|
||||
throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`);
|
||||
}
|
||||
const { account, parsed } = resolveOutboundContext({ cfg, accountId, to });
|
||||
|
||||
// Configure the API client for uploads
|
||||
configureClient({
|
||||
@ -235,15 +245,7 @@ const tlonOutbound: ChannelOutboundAdapter = {
|
||||
});
|
||||
|
||||
const uploadedUrl = mediaUrl ? await uploadImageFromUrl(mediaUrl) : undefined;
|
||||
|
||||
const api = await createHttpPokeApi({
|
||||
url: account.url,
|
||||
ship: account.ship,
|
||||
code: account.code,
|
||||
allowPrivateNetwork: account.allowPrivateNetwork ?? undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
return withHttpPokeAccountApi(account, async (api) => {
|
||||
const fromShip = normalizeShip(account.ship);
|
||||
const story = buildMediaStory(text, uploadedUrl);
|
||||
|
||||
@ -255,22 +257,15 @@ const tlonOutbound: ChannelOutboundAdapter = {
|
||||
story,
|
||||
});
|
||||
}
|
||||
const replyId = (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined;
|
||||
return await sendGroupMessageWithStory({
|
||||
api,
|
||||
fromShip,
|
||||
hostShip: parsed.hostShip,
|
||||
channelName: parsed.channelName,
|
||||
story,
|
||||
replyToId: replyId,
|
||||
replyToId: resolveReplyId(replyToId, threadId),
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
await api.delete();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -115,20 +115,7 @@ export class UrbitSSEClient {
|
||||
app: string;
|
||||
path: string;
|
||||
}) {
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: this.url,
|
||||
path: `/~/channel/${this.channelId}`,
|
||||
init: {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
body: JSON.stringify([subscription]),
|
||||
},
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
const { response, release } = await this.putChannelPayload([subscription], {
|
||||
timeoutMs: 30_000,
|
||||
auditContext: "tlon-urbit-subscribe",
|
||||
});
|
||||
@ -359,20 +346,7 @@ export class UrbitSSEClient {
|
||||
"event-id": eventId,
|
||||
};
|
||||
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: this.url,
|
||||
path: `/~/channel/${this.channelId}`,
|
||||
init: {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
body: JSON.stringify([ackData]),
|
||||
},
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
const { response, release } = await this.putChannelPayload([ackData], {
|
||||
timeoutMs: 10_000,
|
||||
auditContext: "tlon-urbit-ack",
|
||||
});
|
||||
@ -445,20 +419,7 @@ export class UrbitSSEClient {
|
||||
}));
|
||||
|
||||
{
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: this.url,
|
||||
path: `/~/channel/${this.channelId}`,
|
||||
init: {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
body: JSON.stringify(unsubscribes),
|
||||
},
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
const { response, release } = await this.putChannelPayload(unsubscribes, {
|
||||
timeoutMs: 30_000,
|
||||
auditContext: "tlon-urbit-unsubscribe",
|
||||
});
|
||||
@ -501,4 +462,27 @@ export class UrbitSSEClient {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
private async putChannelPayload(
|
||||
payload: unknown,
|
||||
params: { timeoutMs: number; auditContext: string },
|
||||
) {
|
||||
return await urbitFetch({
|
||||
baseUrl: this.url,
|
||||
path: `/~/channel/${this.channelId}`,
|
||||
init: {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
timeoutMs: params.timeoutMs,
|
||||
auditContext: params.auditContext,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,11 +2,9 @@ import {
|
||||
buildVllmProvider,
|
||||
configureOpenAICompatibleSelfHostedProviderNonInteractive,
|
||||
emptyPluginConfigSchema,
|
||||
promptAndConfigureOpenAICompatibleSelfHostedProvider,
|
||||
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth,
|
||||
type OpenClawPluginApi,
|
||||
type ProviderAuthContext,
|
||||
type ProviderAuthMethodNonInteractiveContext,
|
||||
type ProviderAuthResult,
|
||||
type ProviderDiscoveryContext,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
|
||||
@ -30,8 +28,8 @@ const vllmPlugin = {
|
||||
label: "vLLM",
|
||||
hint: "Local/self-hosted OpenAI-compatible server",
|
||||
kind: "custom",
|
||||
run: async (ctx: ProviderAuthContext): Promise<ProviderAuthResult> => {
|
||||
const result = await promptAndConfigureOpenAICompatibleSelfHostedProvider({
|
||||
run: (ctx) =>
|
||||
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({
|
||||
cfg: ctx.config,
|
||||
prompter: ctx.prompter,
|
||||
providerId: PROVIDER_ID,
|
||||
@ -39,18 +37,7 @@ const vllmPlugin = {
|
||||
defaultBaseUrl: DEFAULT_BASE_URL,
|
||||
defaultApiKeyEnvVar: "VLLM_API_KEY",
|
||||
modelPlaceholder: "meta-llama/Meta-Llama-3-8B-Instruct",
|
||||
});
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId: result.profileId,
|
||||
credential: result.credential,
|
||||
},
|
||||
],
|
||||
configPatch: result.config,
|
||||
defaultModel: result.modelRef,
|
||||
};
|
||||
},
|
||||
}),
|
||||
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
|
||||
configureOpenAICompatibleSelfHostedProviderNonInteractive({
|
||||
ctx,
|
||||
|
||||
@ -227,6 +227,35 @@ const voiceCallPlugin = {
|
||||
params.respond(true, { callId: result.callId, initiated: true });
|
||||
};
|
||||
|
||||
const respondToCallMessageAction = async (params: {
|
||||
requestParams: GatewayRequestHandlerOptions["params"];
|
||||
respond: GatewayRequestHandlerOptions["respond"];
|
||||
action: (request: Awaited<ReturnType<typeof resolveCallMessageRequest>>) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
transcript?: string;
|
||||
}>;
|
||||
failure: string;
|
||||
includeTranscript?: boolean;
|
||||
}) => {
|
||||
const request = await resolveCallMessageRequest(params.requestParams);
|
||||
if ("error" in request) {
|
||||
params.respond(false, { error: request.error });
|
||||
return;
|
||||
}
|
||||
const result = await params.action(request);
|
||||
if (!result.success) {
|
||||
params.respond(false, { error: result.error || params.failure });
|
||||
return;
|
||||
}
|
||||
params.respond(
|
||||
true,
|
||||
params.includeTranscript
|
||||
? { success: true, transcript: result.transcript }
|
||||
: { success: true },
|
||||
);
|
||||
};
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"voicecall.initiate",
|
||||
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
||||
@ -264,17 +293,13 @@ const voiceCallPlugin = {
|
||||
"voicecall.continue",
|
||||
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
||||
try {
|
||||
const request = await resolveCallMessageRequest(params);
|
||||
if ("error" in request) {
|
||||
respond(false, { error: request.error });
|
||||
return;
|
||||
}
|
||||
const result = await request.rt.manager.continueCall(request.callId, request.message);
|
||||
if (!result.success) {
|
||||
respond(false, { error: result.error || "continue failed" });
|
||||
return;
|
||||
}
|
||||
respond(true, { success: true, transcript: result.transcript });
|
||||
await respondToCallMessageAction({
|
||||
requestParams: params,
|
||||
respond,
|
||||
action: (request) => request.rt.manager.continueCall(request.callId, request.message),
|
||||
failure: "continue failed",
|
||||
includeTranscript: true,
|
||||
});
|
||||
} catch (err) {
|
||||
sendError(respond, err);
|
||||
}
|
||||
@ -285,17 +310,12 @@ const voiceCallPlugin = {
|
||||
"voicecall.speak",
|
||||
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
||||
try {
|
||||
const request = await resolveCallMessageRequest(params);
|
||||
if ("error" in request) {
|
||||
respond(false, { error: request.error });
|
||||
return;
|
||||
}
|
||||
const result = await request.rt.manager.speak(request.callId, request.message);
|
||||
if (!result.success) {
|
||||
respond(false, { error: result.error || "speak failed" });
|
||||
return;
|
||||
}
|
||||
respond(true, { success: true });
|
||||
await respondToCallMessageAction({
|
||||
requestParams: params,
|
||||
respond,
|
||||
action: (request) => request.rt.manager.speak(request.callId, request.message),
|
||||
failure: "speak failed",
|
||||
});
|
||||
} catch (err) {
|
||||
sendError(respond, err);
|
||||
}
|
||||
|
||||
10
extensions/zalouser/src/accounts.test-mocks.ts
Normal file
10
extensions/zalouser/src/accounts.test-mocks.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { vi } from "vitest";
|
||||
import { createDefaultResolvedZalouserAccount } from "./test-helpers.js";
|
||||
|
||||
vi.mock("./accounts.js", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
resolveZalouserAccountSync: () => createDefaultResolvedZalouserAccount(),
|
||||
};
|
||||
});
|
||||
@ -1,5 +1,6 @@
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/zalouser";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import "./accounts.test-mocks.js";
|
||||
import { createZalouserRuntimeEnv } from "./test-helpers.js";
|
||||
|
||||
const listZaloGroupMembersMock = vi.hoisted(() => vi.fn(async () => []));
|
||||
|
||||
@ -11,30 +12,9 @@ vi.mock("./zalo-js.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./accounts.js", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
resolveZalouserAccountSync: () => ({
|
||||
accountId: "default",
|
||||
profile: "default",
|
||||
name: "test",
|
||||
enabled: true,
|
||||
authenticated: true,
|
||||
config: {},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { zalouserPlugin } from "./channel.js";
|
||||
|
||||
const runtimeStub: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as RuntimeEnv["exit"],
|
||||
};
|
||||
const runtimeStub = createZalouserRuntimeEnv();
|
||||
|
||||
describe("zalouser directory group members", () => {
|
||||
it("accepts prefixed group ids from directory groups list output", async () => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/zalouser";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "./accounts.test-mocks.js";
|
||||
import {
|
||||
installSendPayloadContractSuite,
|
||||
primeSendMock,
|
||||
@ -12,20 +13,6 @@ vi.mock("./send.js", () => ({
|
||||
sendReactionZalouser: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}));
|
||||
|
||||
vi.mock("./accounts.js", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
resolveZalouserAccountSync: () => ({
|
||||
accountId: "default",
|
||||
profile: "default",
|
||||
name: "test",
|
||||
enabled: true,
|
||||
config: {},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function baseCtx(payload: ReplyPayload) {
|
||||
return {
|
||||
cfg: {},
|
||||
|
||||
@ -4,6 +4,7 @@ import "./monitor.send-mocks.js";
|
||||
import { __testing } from "./monitor.js";
|
||||
import { sendMessageZalouserMock } from "./monitor.send-mocks.js";
|
||||
import { setZalouserRuntime } from "./runtime.js";
|
||||
import { createZalouserRuntimeEnv } from "./test-helpers.js";
|
||||
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
|
||||
|
||||
describe("zalouser monitor pairing account scoping", () => {
|
||||
@ -80,19 +81,11 @@ describe("zalouser monitor pairing account scoping", () => {
|
||||
raw: { source: "test" },
|
||||
};
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as RuntimeEnv["exit"],
|
||||
};
|
||||
|
||||
await __testing.processMessage({
|
||||
message,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
runtime: createZalouserRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(readAllowFromStore).toHaveBeenCalledWith(
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
sendTypingZalouserMock,
|
||||
} from "./monitor.send-mocks.js";
|
||||
import { setZalouserRuntime } from "./runtime.js";
|
||||
import { createZalouserRuntimeEnv } from "./test-helpers.js";
|
||||
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
|
||||
|
||||
function createAccount(): ResolvedZalouserAccount {
|
||||
@ -39,15 +40,7 @@ function createConfig(): OpenClawConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as RuntimeEnv["exit"],
|
||||
};
|
||||
}
|
||||
const createRuntimeEnv = () => createZalouserRuntimeEnv();
|
||||
|
||||
function installRuntime(params: {
|
||||
commandAuthorized?: boolean;
|
||||
@ -269,7 +262,7 @@ describe("zalouser monitor group mention gating", () => {
|
||||
message: params.message,
|
||||
account: params.account ?? createAccount(),
|
||||
config: createConfig(),
|
||||
runtime: createRuntimeEnv(),
|
||||
runtime: createZalouserRuntimeEnv(),
|
||||
historyState: params.historyState,
|
||||
});
|
||||
}
|
||||
|
||||
26
extensions/zalouser/src/test-helpers.ts
Normal file
26
extensions/zalouser/src/test-helpers.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/zalouser";
|
||||
import type { ResolvedZalouserAccount } from "./types.js";
|
||||
|
||||
export function createZalouserRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
exit: ((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as RuntimeEnv["exit"],
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultResolvedZalouserAccount(
|
||||
overrides: Partial<ResolvedZalouserAccount> = {},
|
||||
): ResolvedZalouserAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
profile: "default",
|
||||
name: "test",
|
||||
enabled: true,
|
||||
authenticated: true,
|
||||
config: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// On Windows, `.cmd` launchers can fail with `spawn EINVAL` when invoked without a shell
|
||||
// (especially under GitHub Actions + Git Bash). Use `shell: true` and let the shell resolve pnpm.
|
||||
@ -205,6 +206,45 @@ const shardIndexOverride = (() => {
|
||||
const parsed = Number.parseInt(process.env.OPENCLAW_TEST_SHARD_INDEX ?? "", 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
})();
|
||||
const OPTION_TAKES_VALUE = new Set([
|
||||
"-t",
|
||||
"-c",
|
||||
"-r",
|
||||
"--testNamePattern",
|
||||
"--config",
|
||||
"--root",
|
||||
"--dir",
|
||||
"--reporter",
|
||||
"--outputFile",
|
||||
"--pool",
|
||||
"--execArgv",
|
||||
"--vmMemoryLimit",
|
||||
"--maxWorkers",
|
||||
"--environment",
|
||||
"--shard",
|
||||
"--changed",
|
||||
"--sequence",
|
||||
"--inspect",
|
||||
"--inspectBrk",
|
||||
"--testTimeout",
|
||||
"--hookTimeout",
|
||||
"--bail",
|
||||
"--retry",
|
||||
"--diff",
|
||||
"--exclude",
|
||||
"--project",
|
||||
"--slowTestThreshold",
|
||||
"--teardownTimeout",
|
||||
"--attachmentsDir",
|
||||
"--mode",
|
||||
"--api",
|
||||
"--browser",
|
||||
"--maxConcurrency",
|
||||
"--mergeReports",
|
||||
"--configLoader",
|
||||
"--experimental",
|
||||
]);
|
||||
const SINGLE_RUN_ONLY_FLAGS = new Set(["--coverage", "--outputFile", "--mergeReports"]);
|
||||
|
||||
if (shardIndexOverride !== null && shardCount <= 1) {
|
||||
console.error(
|
||||
@ -229,6 +269,219 @@ const silentArgs =
|
||||
const rawPassthroughArgs = process.argv.slice(2);
|
||||
const passthroughArgs =
|
||||
rawPassthroughArgs[0] === "--" ? rawPassthroughArgs.slice(1) : rawPassthroughArgs;
|
||||
const parsePassthroughArgs = (args) => {
|
||||
const fileFilters = [];
|
||||
const optionArgs = [];
|
||||
let consumeNextAsOptionValue = false;
|
||||
|
||||
for (const arg of args) {
|
||||
if (consumeNextAsOptionValue) {
|
||||
optionArgs.push(arg);
|
||||
consumeNextAsOptionValue = false;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--") {
|
||||
optionArgs.push(arg);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("-")) {
|
||||
optionArgs.push(arg);
|
||||
consumeNextAsOptionValue = !arg.includes("=") && OPTION_TAKES_VALUE.has(arg);
|
||||
continue;
|
||||
}
|
||||
fileFilters.push(arg);
|
||||
}
|
||||
|
||||
return { fileFilters, optionArgs };
|
||||
};
|
||||
const { fileFilters: passthroughFileFilters, optionArgs: passthroughOptionArgs } =
|
||||
parsePassthroughArgs(passthroughArgs);
|
||||
const passthroughRequiresSingleRun = passthroughOptionArgs.some((arg) => {
|
||||
if (!arg.startsWith("-")) {
|
||||
return false;
|
||||
}
|
||||
const [flag] = arg.split("=", 1);
|
||||
return SINGLE_RUN_ONLY_FLAGS.has(flag);
|
||||
});
|
||||
const channelPrefixes = ["src/telegram/", "src/discord/", "src/web/", "src/browser/", "src/line/"];
|
||||
const baseConfigPrefixes = ["src/agents/", "src/auto-reply/", "src/commands/", "test/", "ui/"];
|
||||
const normalizeRepoPath = (value) => value.split(path.sep).join("/");
|
||||
const walkTestFiles = (rootDir) => {
|
||||
if (!fs.existsSync(rootDir)) {
|
||||
return [];
|
||||
}
|
||||
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(rootDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...walkTestFiles(fullPath));
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
fullPath.endsWith(".test.ts") ||
|
||||
fullPath.endsWith(".live.test.ts") ||
|
||||
fullPath.endsWith(".e2e.test.ts")
|
||||
) {
|
||||
files.push(normalizeRepoPath(fullPath));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
};
|
||||
const allKnownTestFiles = [
|
||||
...new Set([
|
||||
...walkTestFiles("src"),
|
||||
...walkTestFiles("extensions"),
|
||||
...walkTestFiles("test"),
|
||||
...walkTestFiles(path.join("ui", "src", "ui")),
|
||||
]),
|
||||
];
|
||||
const inferTarget = (fileFilter) => {
|
||||
const isolated = unitIsolatedFiles.includes(fileFilter);
|
||||
if (fileFilter.endsWith(".live.test.ts")) {
|
||||
return { owner: "live", isolated };
|
||||
}
|
||||
if (fileFilter.endsWith(".e2e.test.ts")) {
|
||||
return { owner: "e2e", isolated };
|
||||
}
|
||||
if (fileFilter.startsWith("extensions/")) {
|
||||
return { owner: "extensions", isolated };
|
||||
}
|
||||
if (fileFilter.startsWith("src/gateway/")) {
|
||||
return { owner: "gateway", isolated };
|
||||
}
|
||||
if (channelPrefixes.some((prefix) => fileFilter.startsWith(prefix))) {
|
||||
return { owner: "channels", isolated };
|
||||
}
|
||||
if (baseConfigPrefixes.some((prefix) => fileFilter.startsWith(prefix))) {
|
||||
return { owner: "base", isolated };
|
||||
}
|
||||
if (fileFilter.startsWith("src/")) {
|
||||
return { owner: "unit", isolated };
|
||||
}
|
||||
return { owner: "base", isolated };
|
||||
};
|
||||
const resolveFilterMatches = (fileFilter) => {
|
||||
const normalizedFilter = normalizeRepoPath(fileFilter);
|
||||
if (fs.existsSync(fileFilter)) {
|
||||
const stats = fs.statSync(fileFilter);
|
||||
if (stats.isFile()) {
|
||||
return [normalizedFilter];
|
||||
}
|
||||
if (stats.isDirectory()) {
|
||||
const prefix = normalizedFilter.endsWith("/") ? normalizedFilter : `${normalizedFilter}/`;
|
||||
return allKnownTestFiles.filter((file) => file.startsWith(prefix));
|
||||
}
|
||||
}
|
||||
if (/[*?[\]{}]/.test(normalizedFilter)) {
|
||||
return allKnownTestFiles.filter((file) => path.matchesGlob(file, normalizedFilter));
|
||||
}
|
||||
return allKnownTestFiles.filter((file) => file.includes(normalizedFilter));
|
||||
};
|
||||
const createTargetedEntry = (owner, isolated, filters) => {
|
||||
const name = isolated ? `${owner}-isolated` : owner;
|
||||
const forceForks = isolated;
|
||||
if (owner === "unit") {
|
||||
return {
|
||||
name,
|
||||
args: [
|
||||
"vitest",
|
||||
"run",
|
||||
"--config",
|
||||
"vitest.unit.config.ts",
|
||||
`--pool=${forceForks ? "forks" : useVmForks ? "vmForks" : "forks"}`,
|
||||
...(disableIsolation ? ["--isolate=false"] : []),
|
||||
...filters,
|
||||
],
|
||||
};
|
||||
}
|
||||
if (owner === "extensions") {
|
||||
return {
|
||||
name,
|
||||
args: [
|
||||
"vitest",
|
||||
"run",
|
||||
"--config",
|
||||
"vitest.extensions.config.ts",
|
||||
...(forceForks ? ["--pool=forks"] : useVmForks ? ["--pool=vmForks"] : []),
|
||||
...filters,
|
||||
],
|
||||
};
|
||||
}
|
||||
if (owner === "gateway") {
|
||||
return {
|
||||
name,
|
||||
args: ["vitest", "run", "--config", "vitest.gateway.config.ts", "--pool=forks", ...filters],
|
||||
};
|
||||
}
|
||||
if (owner === "channels") {
|
||||
return {
|
||||
name,
|
||||
args: [
|
||||
"vitest",
|
||||
"run",
|
||||
"--config",
|
||||
"vitest.channels.config.ts",
|
||||
...(forceForks ? ["--pool=forks"] : []),
|
||||
...filters,
|
||||
],
|
||||
};
|
||||
}
|
||||
if (owner === "live") {
|
||||
return {
|
||||
name,
|
||||
args: ["vitest", "run", "--config", "vitest.live.config.ts", ...filters],
|
||||
};
|
||||
}
|
||||
if (owner === "e2e") {
|
||||
return {
|
||||
name,
|
||||
args: ["vitest", "run", "--config", "vitest.e2e.config.ts", ...filters],
|
||||
};
|
||||
}
|
||||
return {
|
||||
name,
|
||||
args: [
|
||||
"vitest",
|
||||
"run",
|
||||
"--config",
|
||||
"vitest.config.ts",
|
||||
...(forceForks ? ["--pool=forks"] : []),
|
||||
...filters,
|
||||
],
|
||||
};
|
||||
};
|
||||
const targetedEntries = (() => {
|
||||
if (passthroughFileFilters.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const groups = passthroughFileFilters.reduce((acc, fileFilter) => {
|
||||
const matchedFiles = resolveFilterMatches(fileFilter);
|
||||
if (matchedFiles.length === 0) {
|
||||
const target = inferTarget(normalizeRepoPath(fileFilter));
|
||||
const key = `${target.owner}:${target.isolated ? "isolated" : "default"}`;
|
||||
const files = acc.get(key) ?? [];
|
||||
files.push(normalizeRepoPath(fileFilter));
|
||||
acc.set(key, files);
|
||||
return acc;
|
||||
}
|
||||
for (const matchedFile of matchedFiles) {
|
||||
const target = inferTarget(matchedFile);
|
||||
const key = `${target.owner}:${target.isolated ? "isolated" : "default"}`;
|
||||
const files = acc.get(key) ?? [];
|
||||
files.push(matchedFile);
|
||||
acc.set(key, files);
|
||||
}
|
||||
return acc;
|
||||
}, new Map());
|
||||
return Array.from(groups, ([key, filters]) => {
|
||||
const [owner, mode] = key.split(":");
|
||||
return createTargetedEntry(owner, mode === "isolated", [...new Set(filters)]);
|
||||
});
|
||||
})();
|
||||
const topLevelParallelEnabled = testProfile !== "low" && testProfile !== "serial";
|
||||
const overrideWorkers = Number.parseInt(process.env.OPENCLAW_TEST_WORKERS ?? "", 10);
|
||||
const resolvedOverride =
|
||||
@ -311,7 +564,7 @@ const maxWorkersForRun = (name) => {
|
||||
if (isCI && isMacOS) {
|
||||
return 1;
|
||||
}
|
||||
if (name === "unit-isolated") {
|
||||
if (name === "unit-isolated" || name.endsWith("-isolated")) {
|
||||
return defaultWorkerBudget.unitIsolated;
|
||||
}
|
||||
if (name === "extensions") {
|
||||
@ -397,16 +650,16 @@ const runOnce = (entry, extraArgs = []) =>
|
||||
});
|
||||
});
|
||||
|
||||
const run = async (entry) => {
|
||||
const run = async (entry, extraArgs = []) => {
|
||||
if (shardCount <= 1) {
|
||||
return runOnce(entry);
|
||||
return runOnce(entry, extraArgs);
|
||||
}
|
||||
if (shardIndexOverride !== null) {
|
||||
return runOnce(entry, ["--shard", `${shardIndexOverride}/${shardCount}`]);
|
||||
return runOnce(entry, ["--shard", `${shardIndexOverride}/${shardCount}`, ...extraArgs]);
|
||||
}
|
||||
for (let shardIndex = 1; shardIndex <= shardCount; shardIndex += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const code = await runOnce(entry, ["--shard", `${shardIndex}/${shardCount}`]);
|
||||
const code = await runOnce(entry, ["--shard", `${shardIndex}/${shardCount}`, ...extraArgs]);
|
||||
if (code !== 0) {
|
||||
return code;
|
||||
}
|
||||
@ -414,15 +667,15 @@ const run = async (entry) => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
const runEntries = async (entries) => {
|
||||
const runEntries = async (entries, extraArgs = []) => {
|
||||
if (topLevelParallelEnabled) {
|
||||
const codes = await Promise.all(entries.map(run));
|
||||
const codes = await Promise.all(entries.map((entry) => run(entry, extraArgs)));
|
||||
return codes.find((code) => code !== 0);
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const code = await run(entry);
|
||||
const code = await run(entry, extraArgs);
|
||||
if (code !== 0) {
|
||||
return code;
|
||||
}
|
||||
@ -440,57 +693,48 @@ const shutdown = (signal) => {
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
|
||||
if (passthroughArgs.length > 0) {
|
||||
const maxWorkers = maxWorkersForRun("unit");
|
||||
const args = maxWorkers
|
||||
? [
|
||||
"vitest",
|
||||
"run",
|
||||
"--maxWorkers",
|
||||
String(maxWorkers),
|
||||
...silentArgs,
|
||||
...windowsCiArgs,
|
||||
...passthroughArgs,
|
||||
]
|
||||
: ["vitest", "run", ...silentArgs, ...windowsCiArgs, ...passthroughArgs];
|
||||
const nodeOptions = process.env.NODE_OPTIONS ?? "";
|
||||
const nextNodeOptions = WARNING_SUPPRESSION_FLAGS.reduce(
|
||||
(acc, flag) => (acc.includes(flag) ? acc : `${acc} ${flag}`.trim()),
|
||||
nodeOptions,
|
||||
);
|
||||
const code = await new Promise((resolve) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn(pnpm, args, {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, NODE_OPTIONS: nextNodeOptions },
|
||||
shell: isWindows,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[test-parallel] spawn failed: ${String(err)}`);
|
||||
resolve(1);
|
||||
return;
|
||||
if (targetedEntries.length > 0) {
|
||||
if (passthroughRequiresSingleRun && targetedEntries.length > 1) {
|
||||
console.error(
|
||||
"[test-parallel] The provided Vitest args require a single run, but the selected test filters span multiple wrapper configs. Run one target/config at a time.",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const targetedParallelRuns = keepGatewaySerial
|
||||
? targetedEntries.filter((entry) => entry.name !== "gateway")
|
||||
: targetedEntries;
|
||||
const targetedSerialRuns = keepGatewaySerial
|
||||
? targetedEntries.filter((entry) => entry.name === "gateway")
|
||||
: [];
|
||||
const failedTargetedParallel = await runEntries(targetedParallelRuns, passthroughOptionArgs);
|
||||
if (failedTargetedParallel !== undefined) {
|
||||
process.exit(failedTargetedParallel);
|
||||
}
|
||||
for (const entry of targetedSerialRuns) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const code = await run(entry, passthroughOptionArgs);
|
||||
if (code !== 0) {
|
||||
process.exit(code);
|
||||
}
|
||||
children.add(child);
|
||||
child.on("error", (err) => {
|
||||
console.error(`[test-parallel] child error: ${String(err)}`);
|
||||
});
|
||||
child.on("exit", (exitCode, signal) => {
|
||||
children.delete(child);
|
||||
resolve(exitCode ?? (signal ? 1 : 0));
|
||||
});
|
||||
});
|
||||
process.exit(Number(code) || 0);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const failedParallel = await runEntries(parallelRuns);
|
||||
if (passthroughRequiresSingleRun && passthroughOptionArgs.length > 0) {
|
||||
console.error(
|
||||
"[test-parallel] The provided Vitest args require a single run. Use the dedicated npm script for that workflow (for example `pnpm test:coverage`) or target a single test file/filter.",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const failedParallel = await runEntries(parallelRuns, passthroughOptionArgs);
|
||||
if (failedParallel !== undefined) {
|
||||
process.exit(failedParallel);
|
||||
}
|
||||
|
||||
for (const entry of serialRuns) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const code = await run(entry);
|
||||
const code = await run(entry, passthroughOptionArgs);
|
||||
if (code !== 0) {
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
@ -75,6 +75,17 @@ function resolveAnthropicFastServiceTier(enabled: boolean): AnthropicServiceTier
|
||||
return enabled ? "auto" : "standard_only";
|
||||
}
|
||||
|
||||
function hasOpenAiAnthropicToolPayloadCompatFlag(model: { compat?: unknown }): boolean {
|
||||
if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(model.compat as { requiresOpenAiAnthropicToolPayload?: unknown })
|
||||
.requiresOpenAiAnthropicToolPayload === true
|
||||
);
|
||||
}
|
||||
|
||||
function requiresAnthropicToolPayloadCompatibilityForModel(model: {
|
||||
api?: unknown;
|
||||
provider?: unknown;
|
||||
@ -90,15 +101,7 @@ function requiresAnthropicToolPayloadCompatibilityForModel(model: {
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(model.compat as { requiresOpenAiAnthropicToolPayload?: unknown })
|
||||
.requiresOpenAiAnthropicToolPayload === true
|
||||
);
|
||||
return hasOpenAiAnthropicToolPayloadCompatFlag(model);
|
||||
}
|
||||
|
||||
function usesOpenAiFunctionAnthropicToolSchemaForModel(model: {
|
||||
@ -108,13 +111,7 @@ function usesOpenAiFunctionAnthropicToolSchemaForModel(model: {
|
||||
if (typeof model.provider === "string" && usesOpenAiFunctionAnthropicToolSchema(model.provider)) {
|
||||
return true;
|
||||
}
|
||||
if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(model.compat as { requiresOpenAiAnthropicToolPayload?: unknown })
|
||||
.requiresOpenAiAnthropicToolPayload === true
|
||||
);
|
||||
return hasOpenAiAnthropicToolPayloadCompatFlag(model);
|
||||
}
|
||||
|
||||
function usesOpenAiStringModeAnthropicToolChoiceForModel(model: {
|
||||
@ -127,13 +124,7 @@ function usesOpenAiStringModeAnthropicToolChoiceForModel(model: {
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(model.compat as { requiresOpenAiAnthropicToolPayload?: unknown })
|
||||
.requiresOpenAiAnthropicToolPayload === true
|
||||
);
|
||||
return hasOpenAiAnthropicToolPayloadCompatFlag(model);
|
||||
}
|
||||
|
||||
function normalizeOpenAiFunctionAnthropicToolDefinition(
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createSandboxMediaContexts,
|
||||
createSandboxMediaStageConfig,
|
||||
withSandboxMediaTempHome,
|
||||
} from "./stage-sandbox-media.test-harness.js";
|
||||
|
||||
const sandboxMocks = vi.hoisted(() => ({
|
||||
ensureSandboxWorkspaceForSession: vi.fn(),
|
||||
}));
|
||||
const childProcessMocks = vi.hoisted(() => ({
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/sandbox.js", () => sandboxMocks);
|
||||
vi.mock("node:child_process", () => childProcessMocks);
|
||||
|
||||
import { stageSandboxMedia } from "./reply/stage-sandbox-media.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
childProcessMocks.spawn.mockClear();
|
||||
});
|
||||
|
||||
function createRemoteStageParams(home: string): {
|
||||
cfg: ReturnType<typeof createSandboxMediaStageConfig>;
|
||||
workspaceDir: string;
|
||||
sessionKey: string;
|
||||
remoteCacheDir: string;
|
||||
} {
|
||||
const sessionKey = "agent:main:main";
|
||||
vi.mocked(sandboxMocks.ensureSandboxWorkspaceForSession).mockResolvedValue(null);
|
||||
return {
|
||||
cfg: createSandboxMediaStageConfig(home),
|
||||
workspaceDir: join(home, "openclaw"),
|
||||
sessionKey,
|
||||
remoteCacheDir: join(home, ".openclaw", "media", "remote-cache", sessionKey),
|
||||
};
|
||||
}
|
||||
|
||||
function createRemoteContexts(remotePath: string) {
|
||||
const { ctx, sessionCtx } = createSandboxMediaContexts(remotePath);
|
||||
ctx.Provider = "imessage";
|
||||
ctx.MediaRemoteHost = "user@gateway-host";
|
||||
sessionCtx.Provider = "imessage";
|
||||
sessionCtx.MediaRemoteHost = "user@gateway-host";
|
||||
return { ctx, sessionCtx };
|
||||
}
|
||||
|
||||
describe("stageSandboxMedia scp remote paths", () => {
|
||||
it("rejects remote attachment filenames with shell metacharacters before spawning scp", async () => {
|
||||
await withSandboxMediaTempHome("openclaw-triggers-", async (home) => {
|
||||
const { cfg, workspaceDir, sessionKey, remoteCacheDir } = createRemoteStageParams(home);
|
||||
const remotePath = "/Users/demo/Library/Messages/Attachments/ab/cd/evil$(touch pwned).jpg";
|
||||
const { ctx, sessionCtx } = createRemoteContexts(remotePath);
|
||||
|
||||
await stageSandboxMedia({
|
||||
ctx,
|
||||
sessionCtx,
|
||||
cfg,
|
||||
sessionKey,
|
||||
workspaceDir,
|
||||
});
|
||||
|
||||
expect(childProcessMocks.spawn).not.toHaveBeenCalled();
|
||||
await expect(fs.stat(join(remoteCacheDir, basename(remotePath)))).rejects.toThrow();
|
||||
expect(ctx.MediaPath).toBe(remotePath);
|
||||
expect(sessionCtx.MediaPath).toBe(remotePath);
|
||||
expect(ctx.MediaUrl).toBe(remotePath);
|
||||
expect(sessionCtx.MediaUrl).toBe(remotePath);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -40,8 +40,7 @@ import {
|
||||
} from "../tokens.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import {
|
||||
buildEmbeddedRunBaseParams,
|
||||
buildEmbeddedRunContexts,
|
||||
buildEmbeddedRunExecutionParams,
|
||||
resolveModelFallbackOptions,
|
||||
} from "./agent-runner-utils.js";
|
||||
import { type BlockReplyPipeline } from "./block-reply-pipeline.js";
|
||||
@ -308,20 +307,17 @@ export async function runAgentTurnWithFallback(params: {
|
||||
}
|
||||
})();
|
||||
}
|
||||
const { authProfile, embeddedContext, senderContext } = buildEmbeddedRunContexts({
|
||||
run: params.followupRun.run,
|
||||
sessionCtx: params.sessionCtx,
|
||||
hasRepliedRef: params.opts?.hasRepliedRef,
|
||||
provider,
|
||||
});
|
||||
const runBaseParams = buildEmbeddedRunBaseParams({
|
||||
run: params.followupRun.run,
|
||||
provider,
|
||||
model,
|
||||
runId,
|
||||
authProfile,
|
||||
allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe,
|
||||
});
|
||||
const { embeddedContext, senderContext, runBaseParams } = buildEmbeddedRunExecutionParams(
|
||||
{
|
||||
run: params.followupRun.run,
|
||||
sessionCtx: params.sessionCtx,
|
||||
hasRepliedRef: params.opts?.hasRepliedRef,
|
||||
provider,
|
||||
runId,
|
||||
allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe,
|
||||
model,
|
||||
},
|
||||
);
|
||||
return (async () => {
|
||||
const result = await runEmbeddedPiAgent({
|
||||
...embeddedContext,
|
||||
|
||||
@ -27,8 +27,7 @@ import type { TemplateContext } from "../templating.js";
|
||||
import type { VerboseLevel } from "../thinking.js";
|
||||
import type { GetReplyOptions } from "../types.js";
|
||||
import {
|
||||
buildEmbeddedRunBaseParams,
|
||||
buildEmbeddedRunContexts,
|
||||
buildEmbeddedRunExecutionParams,
|
||||
resolveModelFallbackOptions,
|
||||
} from "./agent-runner-utils.js";
|
||||
import {
|
||||
@ -482,18 +481,13 @@ export async function runMemoryFlushIfNeeded(params: {
|
||||
...resolveModelFallbackOptions(params.followupRun.run),
|
||||
runId: flushRunId,
|
||||
run: async (provider, model, runOptions) => {
|
||||
const { authProfile, embeddedContext, senderContext } = buildEmbeddedRunContexts({
|
||||
const { embeddedContext, senderContext, runBaseParams } = buildEmbeddedRunExecutionParams({
|
||||
run: params.followupRun.run,
|
||||
sessionCtx: params.sessionCtx,
|
||||
hasRepliedRef: params.opts?.hasRepliedRef,
|
||||
provider,
|
||||
});
|
||||
const runBaseParams = buildEmbeddedRunBaseParams({
|
||||
run: params.followupRun.run,
|
||||
provider,
|
||||
model,
|
||||
runId: flushRunId,
|
||||
authProfile,
|
||||
allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe,
|
||||
});
|
||||
const result = await runEmbeddedPiAgent({
|
||||
|
||||
@ -9,6 +9,20 @@ const baseParams = {
|
||||
replyToMode: "off" as const,
|
||||
};
|
||||
|
||||
async function expectSameTargetRepliesSuppressed(params: { provider: string; to: string }) {
|
||||
const { replyPayloads } = await buildReplyPayloads({
|
||||
...baseParams,
|
||||
payloads: [{ text: "hello world!" }],
|
||||
messageProvider: "heartbeat",
|
||||
originatingChannel: "feishu",
|
||||
originatingTo: "ou_abc123",
|
||||
messagingToolSentTexts: ["different message"],
|
||||
messagingToolSentTargets: [{ tool: "message", provider: params.provider, to: params.to }],
|
||||
});
|
||||
|
||||
expect(replyPayloads).toHaveLength(0);
|
||||
}
|
||||
|
||||
describe("buildReplyPayloads media filter integration", () => {
|
||||
it("strips media URL from payload when in messagingToolSentMediaUrls", async () => {
|
||||
const { replyPayloads } = await buildReplyPayloads({
|
||||
@ -142,31 +156,11 @@ describe("buildReplyPayloads media filter integration", () => {
|
||||
});
|
||||
|
||||
it("suppresses same-target replies when message tool target provider is generic", async () => {
|
||||
const { replyPayloads } = await buildReplyPayloads({
|
||||
...baseParams,
|
||||
payloads: [{ text: "hello world!" }],
|
||||
messageProvider: "heartbeat",
|
||||
originatingChannel: "feishu",
|
||||
originatingTo: "ou_abc123",
|
||||
messagingToolSentTexts: ["different message"],
|
||||
messagingToolSentTargets: [{ tool: "message", provider: "message", to: "ou_abc123" }],
|
||||
});
|
||||
|
||||
expect(replyPayloads).toHaveLength(0);
|
||||
await expectSameTargetRepliesSuppressed({ provider: "message", to: "ou_abc123" });
|
||||
});
|
||||
|
||||
it("suppresses same-target replies when target provider is channel alias", async () => {
|
||||
const { replyPayloads } = await buildReplyPayloads({
|
||||
...baseParams,
|
||||
payloads: [{ text: "hello world!" }],
|
||||
messageProvider: "heartbeat",
|
||||
originatingChannel: "feishu",
|
||||
originatingTo: "ou_abc123",
|
||||
messagingToolSentTexts: ["different message"],
|
||||
messagingToolSentTargets: [{ tool: "message", provider: "lark", to: "ou_abc123" }],
|
||||
});
|
||||
|
||||
expect(replyPayloads).toHaveLength(0);
|
||||
await expectSameTargetRepliesSuppressed({ provider: "lark", to: "ou_abc123" });
|
||||
});
|
||||
|
||||
it("drops all final payloads when block pipeline streamed successfully", async () => {
|
||||
|
||||
@ -263,6 +263,31 @@ export function buildEmbeddedRunContexts(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEmbeddedRunExecutionParams(params: {
|
||||
run: FollowupRun["run"];
|
||||
sessionCtx: TemplateContext;
|
||||
hasRepliedRef: { value: boolean } | undefined;
|
||||
provider: string;
|
||||
model: string;
|
||||
runId: string;
|
||||
allowTransientCooldownProbe?: boolean;
|
||||
}) {
|
||||
const { authProfile, embeddedContext, senderContext } = buildEmbeddedRunContexts(params);
|
||||
const runBaseParams = buildEmbeddedRunBaseParams({
|
||||
run: params.run,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
runId: params.runId,
|
||||
authProfile,
|
||||
allowTransientCooldownProbe: params.allowTransientCooldownProbe,
|
||||
});
|
||||
return {
|
||||
embeddedContext,
|
||||
senderContext,
|
||||
runBaseParams,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProviderScopedAuthProfile(params: {
|
||||
provider: string;
|
||||
primaryProvider: string;
|
||||
|
||||
@ -2,7 +2,7 @@ import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import type { FollowupRun, QueueSettings } from "./queue.js";
|
||||
import { createMockTypingController } from "./test-helpers.js";
|
||||
import { createMockFollowupRun, createMockTypingController } from "./test-helpers.js";
|
||||
|
||||
const runEmbeddedPiAgentMock = vi.fn();
|
||||
const runWithModelFallbackMock = vi.fn();
|
||||
@ -72,32 +72,15 @@ describe("runReplyAgent media path normalization", () => {
|
||||
|
||||
const result = await runReplyAgent({
|
||||
commandBody: "generate",
|
||||
followupRun: {
|
||||
followupRun: createMockFollowupRun({
|
||||
prompt: "generate",
|
||||
enqueuedAt: Date.now(),
|
||||
run: {
|
||||
agentId: "main",
|
||||
agentDir: "/tmp/agent",
|
||||
sessionId: "session",
|
||||
sessionKey: "main",
|
||||
messageProvider: "telegram",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
config: {},
|
||||
provider: "anthropic",
|
||||
model: "claude",
|
||||
thinkLevel: "low",
|
||||
verboseLevel: "off",
|
||||
elevatedLevel: "off",
|
||||
bashElevated: {
|
||||
enabled: false,
|
||||
allowed: false,
|
||||
defaultLevel: "off",
|
||||
},
|
||||
timeoutMs: 1_000,
|
||||
blockReplyBreak: "message_end",
|
||||
},
|
||||
} as unknown as FollowupRun,
|
||||
}) as unknown as FollowupRun,
|
||||
queueKey: "main",
|
||||
resolvedQueue: { mode: "interrupt" } as QueueSettings,
|
||||
shouldSteer: false,
|
||||
|
||||
@ -26,6 +26,22 @@ function normalizeChunkProvider(provider?: string): TextChunkProvider | undefine
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveProviderChunkContext(
|
||||
cfg: OpenClawConfig | undefined,
|
||||
provider?: string,
|
||||
accountId?: string | null,
|
||||
) {
|
||||
const providerKey = normalizeChunkProvider(provider);
|
||||
const providerId = providerKey ? normalizeChannelId(providerKey) : null;
|
||||
const providerChunkLimit = providerId
|
||||
? getChannelDock(providerId)?.outbound?.textChunkLimit
|
||||
: undefined;
|
||||
const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId, {
|
||||
fallbackLimit: providerChunkLimit,
|
||||
});
|
||||
return { providerKey, providerId, textLimit };
|
||||
}
|
||||
|
||||
type ProviderBlockStreamingConfig = {
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
accounts?: Record<string, { blockStreamingCoalesce?: BlockStreamingCoalesceConfig }>;
|
||||
@ -97,14 +113,7 @@ export function resolveEffectiveBlockStreamingConfig(params: {
|
||||
chunking: BlockStreamingChunking;
|
||||
coalescing: BlockStreamingCoalescing;
|
||||
} {
|
||||
const providerKey = normalizeChunkProvider(params.provider);
|
||||
const providerId = providerKey ? normalizeChannelId(providerKey) : null;
|
||||
const providerChunkLimit = providerId
|
||||
? getChannelDock(providerId)?.outbound?.textChunkLimit
|
||||
: undefined;
|
||||
const textLimit = resolveTextChunkLimit(params.cfg, providerKey, params.accountId, {
|
||||
fallbackLimit: providerChunkLimit,
|
||||
});
|
||||
const { textLimit } = resolveProviderChunkContext(params.cfg, params.provider, params.accountId);
|
||||
const chunkingDefaults =
|
||||
params.chunking ?? resolveBlockStreamingChunking(params.cfg, params.provider, params.accountId);
|
||||
const chunkingMax = clampPositiveInteger(params.maxChunkChars, chunkingDefaults.maxChars, {
|
||||
@ -154,21 +163,13 @@ export function resolveBlockStreamingChunking(
|
||||
provider?: string,
|
||||
accountId?: string | null,
|
||||
): BlockStreamingChunking {
|
||||
const providerKey = normalizeChunkProvider(provider);
|
||||
const providerConfigKey = providerKey;
|
||||
const providerId = providerKey ? normalizeChannelId(providerKey) : null;
|
||||
const providerChunkLimit = providerId
|
||||
? getChannelDock(providerId)?.outbound?.textChunkLimit
|
||||
: undefined;
|
||||
const textLimit = resolveTextChunkLimit(cfg, providerConfigKey, accountId, {
|
||||
fallbackLimit: providerChunkLimit,
|
||||
});
|
||||
const { providerKey, textLimit } = resolveProviderChunkContext(cfg, provider, accountId);
|
||||
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
|
||||
|
||||
// When chunkMode="newline", the outbound delivery splits on paragraph boundaries.
|
||||
// The block chunker should flush eagerly on \n\n boundaries during streaming,
|
||||
// regardless of minChars, so each paragraph is sent as its own message.
|
||||
const chunkMode = resolveChunkMode(cfg, providerConfigKey, accountId);
|
||||
const chunkMode = resolveChunkMode(cfg, providerKey, accountId);
|
||||
|
||||
const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX));
|
||||
const maxChars = Math.max(1, Math.min(maxRequested, textLimit));
|
||||
@ -198,20 +199,15 @@ export function resolveBlockStreamingCoalescing(
|
||||
},
|
||||
opts?: { chunkMode?: "length" | "newline" },
|
||||
): BlockStreamingCoalescing | undefined {
|
||||
const providerKey = normalizeChunkProvider(provider);
|
||||
const providerConfigKey = providerKey;
|
||||
const { providerKey, providerId, textLimit } = resolveProviderChunkContext(
|
||||
cfg,
|
||||
provider,
|
||||
accountId,
|
||||
);
|
||||
|
||||
// Resolve the outbound chunkMode so the coalescer can flush on paragraph boundaries
|
||||
// when chunkMode="newline", matching the delivery-time splitting behavior.
|
||||
const chunkMode = opts?.chunkMode ?? resolveChunkMode(cfg, providerConfigKey, accountId);
|
||||
|
||||
const providerId = providerKey ? normalizeChannelId(providerKey) : null;
|
||||
const providerChunkLimit = providerId
|
||||
? getChannelDock(providerId)?.outbound?.textChunkLimit
|
||||
: undefined;
|
||||
const textLimit = resolveTextChunkLimit(cfg, providerConfigKey, accountId, {
|
||||
fallbackLimit: providerChunkLimit,
|
||||
});
|
||||
const chunkMode = opts?.chunkMode ?? resolveChunkMode(cfg, providerKey, accountId);
|
||||
const providerDefaults = providerId
|
||||
? getChannelDock(providerId)?.streaming?.blockStreamingCoalesceDefaults
|
||||
: undefined;
|
||||
|
||||
@ -5,8 +5,8 @@ import {
|
||||
} from "../../../acp/conversation-id.js";
|
||||
import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js";
|
||||
import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js";
|
||||
import { parseAgentSessionKey } from "../../../routing/session-key.js";
|
||||
import type { HandleCommandsParams } from "../commands-types.js";
|
||||
import { parseDiscordParentChannelFromSessionKey } from "../discord-parent-channel.js";
|
||||
import { resolveTelegramConversationId } from "../telegram-context.js";
|
||||
|
||||
export function resolveAcpCommandChannel(params: HandleCommandsParams): string {
|
||||
@ -64,19 +64,6 @@ export function resolveAcpCommandConversationId(params: HandleCommandsParams): s
|
||||
});
|
||||
}
|
||||
|
||||
function parseDiscordParentChannelFromSessionKey(raw: unknown): string | undefined {
|
||||
const sessionKey = normalizeConversationText(raw);
|
||||
if (!sessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
const scoped = parseAgentSessionKey(sessionKey)?.rest ?? sessionKey.toLowerCase();
|
||||
const match = scoped.match(/(?:^|:)channel:([^:]+)$/);
|
||||
if (!match?.[1]) {
|
||||
return undefined;
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function parseDiscordParentChannelFromContext(raw: unknown): string | undefined {
|
||||
const parentId = normalizeConversationText(raw);
|
||||
if (!parentId) {
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
import { getChannelDock } from "../../channels/dock.js";
|
||||
import {
|
||||
authorizeConfigWrite,
|
||||
canBypassConfigWritePolicy,
|
||||
formatConfigWriteDeniedMessage,
|
||||
resolveExplicitConfigWriteTarget,
|
||||
} from "../../channels/plugins/config-writes.js";
|
||||
import { resolveExplicitConfigWriteTarget } from "../../channels/plugins/config-writes.js";
|
||||
import { listPairingChannels } from "../../channels/plugins/pairing.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||
import { normalizeChannelId } from "../../channels/registry.js";
|
||||
@ -36,6 +31,7 @@ import { resolveTelegramAccount } from "../../telegram/accounts.js";
|
||||
import { resolveWhatsAppAccount } from "../../web/accounts.js";
|
||||
import { rejectUnauthorizedCommand, requireCommandFlagEnabled } from "./command-gates.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
import { resolveConfigWriteDeniedText } from "./config-write-authorization.js";
|
||||
|
||||
type AllowlistScope = "dm" | "group" | "all";
|
||||
type AllowlistAction = "list" | "add" | "remove";
|
||||
@ -628,20 +624,19 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
|
||||
accountId: normalizedAccountId,
|
||||
writeTarget,
|
||||
} = resolveAccountTarget(parsedConfig, channelId, accountId);
|
||||
const writeAuth = authorizeConfigWrite({
|
||||
const deniedText = resolveConfigWriteDeniedText({
|
||||
cfg: params.cfg,
|
||||
origin: { channelId, accountId: params.ctx.AccountId },
|
||||
channel: params.command.channel,
|
||||
channelId,
|
||||
accountId: params.ctx.AccountId,
|
||||
gatewayClientScopes: params.ctx.GatewayClientScopes,
|
||||
target: writeTarget,
|
||||
allowBypass: canBypassConfigWritePolicy({
|
||||
channel: params.command.channel,
|
||||
gatewayClientScopes: params.ctx.GatewayClientScopes,
|
||||
}),
|
||||
});
|
||||
if (!writeAuth.allowed) {
|
||||
if (deniedText) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: formatConfigWriteDeniedMessage({ result: writeAuth, fallbackChannelId: channelId }),
|
||||
text: deniedText,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
import {
|
||||
authorizeConfigWrite,
|
||||
canBypassConfigWritePolicy,
|
||||
formatConfigWriteDeniedMessage,
|
||||
resolveConfigWriteTargetFromPath,
|
||||
} from "../../channels/plugins/config-writes.js";
|
||||
import { resolveConfigWriteTargetFromPath } from "../../channels/plugins/config-writes.js";
|
||||
import { normalizeChannelId } from "../../channels/registry.js";
|
||||
import {
|
||||
getConfigValueAtPath,
|
||||
@ -31,6 +26,7 @@ import {
|
||||
} from "./command-gates.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
import { parseConfigCommand } from "./config-commands.js";
|
||||
import { resolveConfigWriteDeniedText } from "./config-write-authorization.js";
|
||||
import { parseDebugCommand } from "./debug-commands.js";
|
||||
|
||||
export const handleConfigCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
@ -84,20 +80,19 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
|
||||
}
|
||||
parsedWritePath = parsedPath.path;
|
||||
const channelId = params.command.channelId ?? normalizeChannelId(params.command.channel);
|
||||
const writeAuth = authorizeConfigWrite({
|
||||
const deniedText = resolveConfigWriteDeniedText({
|
||||
cfg: params.cfg,
|
||||
origin: { channelId, accountId: params.ctx.AccountId },
|
||||
channel: params.command.channel,
|
||||
channelId,
|
||||
accountId: params.ctx.AccountId,
|
||||
gatewayClientScopes: params.ctx.GatewayClientScopes,
|
||||
target: resolveConfigWriteTargetFromPath(parsedWritePath),
|
||||
allowBypass: canBypassConfigWritePolicy({
|
||||
channel: params.command.channel,
|
||||
gatewayClientScopes: params.ctx.GatewayClientScopes,
|
||||
}),
|
||||
});
|
||||
if (!writeAuth.allowed) {
|
||||
if (deniedText) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: formatConfigWriteDeniedMessage({ result: writeAuth, fallbackChannelId: channelId }),
|
||||
text: deniedText,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -86,6 +86,23 @@ async function applyAbortTarget(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function buildAbortTargetApplyParams(
|
||||
params: Parameters<CommandHandler>[0],
|
||||
abortTarget: AbortTarget,
|
||||
) {
|
||||
return {
|
||||
abortTarget,
|
||||
sessionStore: params.sessionStore,
|
||||
storePath: params.storePath,
|
||||
abortKey: params.command.abortKey,
|
||||
abortCutoff: resolveAbortCutoffForTarget({
|
||||
ctx: params.ctx,
|
||||
commandSessionKey: params.sessionKey,
|
||||
targetSessionKey: abortTarget.key,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const handleStopCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
@ -109,17 +126,7 @@ export const handleStopCommand: CommandHandler = async (params, allowTextCommand
|
||||
`stop: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
|
||||
);
|
||||
}
|
||||
await applyAbortTarget({
|
||||
abortTarget,
|
||||
sessionStore: params.sessionStore,
|
||||
storePath: params.storePath,
|
||||
abortKey: params.command.abortKey,
|
||||
abortCutoff: resolveAbortCutoffForTarget({
|
||||
ctx: params.ctx,
|
||||
commandSessionKey: params.sessionKey,
|
||||
targetSessionKey: abortTarget.key,
|
||||
}),
|
||||
});
|
||||
await applyAbortTarget(buildAbortTargetApplyParams(params, abortTarget));
|
||||
|
||||
// Trigger internal hook for stop command
|
||||
const hookEvent = createInternalHookEvent(
|
||||
@ -160,16 +167,6 @@ export const handleAbortTrigger: CommandHandler = async (params, allowTextComman
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionStore: params.sessionStore,
|
||||
});
|
||||
await applyAbortTarget({
|
||||
abortTarget,
|
||||
sessionStore: params.sessionStore,
|
||||
storePath: params.storePath,
|
||||
abortKey: params.command.abortKey,
|
||||
abortCutoff: resolveAbortCutoffForTarget({
|
||||
ctx: params.ctx,
|
||||
commandSessionKey: params.sessionKey,
|
||||
targetSessionKey: abortTarget.key,
|
||||
}),
|
||||
});
|
||||
await applyAbortTarget(buildAbortTargetApplyParams(params, abortTarget));
|
||||
return { shouldContinue: false, reply: { text: "⚙️ Agent was aborted." } };
|
||||
};
|
||||
|
||||
@ -139,6 +139,21 @@ function createTelegramBinding(overrides?: Partial<SessionBindingRecord>): Sessi
|
||||
};
|
||||
}
|
||||
|
||||
function expectIdleTimeoutSetReply(
|
||||
mock: ReturnType<typeof vi.fn>,
|
||||
text: string,
|
||||
idleTimeoutMs: number,
|
||||
idleTimeoutLabel: string,
|
||||
) {
|
||||
expect(mock).toHaveBeenCalledWith({
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
accountId: "default",
|
||||
idleTimeoutMs,
|
||||
});
|
||||
expect(text).toContain(`Idle timeout set to ${idleTimeoutLabel}`);
|
||||
expect(text).toContain("2026-02-20T02:00:00.000Z");
|
||||
}
|
||||
|
||||
function createFakeThreadBindingManager(binding: FakeBinding | null) {
|
||||
return {
|
||||
getByThreadId: vi.fn((_threadId: string) => binding),
|
||||
@ -175,13 +190,12 @@ describe("/session idle and /session max-age", () => {
|
||||
const result = await handleSessionCommand(createDiscordCommandParams("/session idle 2h"), true);
|
||||
const text = result?.reply?.text ?? "";
|
||||
|
||||
expect(hoisted.setThreadBindingIdleTimeoutBySessionKeyMock).toHaveBeenCalledWith({
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
accountId: "default",
|
||||
idleTimeoutMs: 2 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(text).toContain("Idle timeout set to 2h");
|
||||
expect(text).toContain("2026-02-20T02:00:00.000Z");
|
||||
expectIdleTimeoutSetReply(
|
||||
hoisted.setThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
text,
|
||||
2 * 60 * 60 * 1000,
|
||||
"2h",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows active idle timeout when no value is provided", async () => {
|
||||
@ -248,13 +262,12 @@ describe("/session idle and /session max-age", () => {
|
||||
);
|
||||
const text = result?.reply?.text ?? "";
|
||||
|
||||
expect(hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock).toHaveBeenCalledWith({
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
accountId: "default",
|
||||
idleTimeoutMs: 2 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(text).toContain("Idle timeout set to 2h");
|
||||
expect(text).toContain("2026-02-20T02:00:00.000Z");
|
||||
expectIdleTimeoutSetReply(
|
||||
hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock,
|
||||
text,
|
||||
2 * 60 * 60 * 1000,
|
||||
"2h",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports Telegram max-age expiry from the original bind time", async () => {
|
||||
|
||||
33
src/auto-reply/reply/config-write-authorization.ts
Normal file
33
src/auto-reply/reply/config-write-authorization.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import {
|
||||
authorizeConfigWrite,
|
||||
canBypassConfigWritePolicy,
|
||||
formatConfigWriteDeniedMessage,
|
||||
} from "../../channels/plugins/config-writes.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
|
||||
export function resolveConfigWriteDeniedText(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel?: string | null;
|
||||
channelId: ChannelId | null;
|
||||
accountId?: string;
|
||||
gatewayClientScopes?: string[];
|
||||
target: Parameters<typeof authorizeConfigWrite>[0]["target"];
|
||||
}): string | null {
|
||||
const writeAuth = authorizeConfigWrite({
|
||||
cfg: params.cfg,
|
||||
origin: { channelId: params.channelId, accountId: params.accountId },
|
||||
target: params.target,
|
||||
allowBypass: canBypassConfigWritePolicy({
|
||||
channel: params.channel ?? "",
|
||||
gatewayClientScopes: params.gatewayClientScopes,
|
||||
}),
|
||||
});
|
||||
if (writeAuth.allowed) {
|
||||
return null;
|
||||
}
|
||||
return formatConfigWriteDeniedMessage({
|
||||
result: writeAuth,
|
||||
fallbackChannelId: params.channelId,
|
||||
});
|
||||
}
|
||||
@ -4,6 +4,11 @@ import type { OpenClawConfig } from "../../config/config.js";
|
||||
|
||||
let mockStore: AuthProfileStore;
|
||||
let mockOrder: string[];
|
||||
const githubCopilotTokenRefProfile: AuthProfileStore["profiles"][string] = {
|
||||
type: "token",
|
||||
provider: "github-copilot",
|
||||
tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" },
|
||||
};
|
||||
|
||||
vi.mock("../../agents/auth-health.js", () => ({
|
||||
formatRemainingShort: () => "1h",
|
||||
@ -39,6 +44,28 @@ vi.mock("../../agents/model-auth.js", () => ({
|
||||
|
||||
const { resolveAuthLabel } = await import("./directive-handling.auth.js");
|
||||
|
||||
async function resolveRefOnlyAuthLabel(params: {
|
||||
provider: string;
|
||||
profileId: string;
|
||||
profile:
|
||||
| (AuthProfileStore["profiles"][string] & { type: "api_key" })
|
||||
| (AuthProfileStore["profiles"][string] & { type: "token" });
|
||||
mode: "compact" | "verbose";
|
||||
}) {
|
||||
mockStore.profiles = {
|
||||
[params.profileId]: params.profile,
|
||||
};
|
||||
mockOrder = [params.profileId];
|
||||
|
||||
return resolveAuthLabel(
|
||||
params.provider,
|
||||
{} as OpenClawConfig,
|
||||
"/tmp/models.json",
|
||||
undefined,
|
||||
params.mode,
|
||||
);
|
||||
}
|
||||
|
||||
describe("resolveAuthLabel ref-aware labels", () => {
|
||||
beforeEach(() => {
|
||||
mockStore = {
|
||||
@ -49,64 +76,38 @@ describe("resolveAuthLabel ref-aware labels", () => {
|
||||
});
|
||||
|
||||
it("shows api-key (ref) for keyRef-only profiles in compact mode", async () => {
|
||||
mockStore.profiles = {
|
||||
"openai:default": {
|
||||
const result = await resolveRefOnlyAuthLabel({
|
||||
provider: "openai",
|
||||
profileId: "openai:default",
|
||||
profile: {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
},
|
||||
};
|
||||
mockOrder = ["openai:default"];
|
||||
|
||||
const result = await resolveAuthLabel(
|
||||
"openai",
|
||||
{} as OpenClawConfig,
|
||||
"/tmp/models.json",
|
||||
undefined,
|
||||
"compact",
|
||||
);
|
||||
mode: "compact",
|
||||
});
|
||||
|
||||
expect(result.label).toBe("openai:default api-key (ref)");
|
||||
});
|
||||
|
||||
it("shows token (ref) for tokenRef-only profiles in compact mode", async () => {
|
||||
mockStore.profiles = {
|
||||
"github-copilot:default": {
|
||||
type: "token",
|
||||
provider: "github-copilot",
|
||||
tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" },
|
||||
},
|
||||
};
|
||||
mockOrder = ["github-copilot:default"];
|
||||
|
||||
const result = await resolveAuthLabel(
|
||||
"github-copilot",
|
||||
{} as OpenClawConfig,
|
||||
"/tmp/models.json",
|
||||
undefined,
|
||||
"compact",
|
||||
);
|
||||
const result = await resolveRefOnlyAuthLabel({
|
||||
provider: "github-copilot",
|
||||
profileId: "github-copilot:default",
|
||||
profile: githubCopilotTokenRefProfile,
|
||||
mode: "compact",
|
||||
});
|
||||
|
||||
expect(result.label).toBe("github-copilot:default token (ref)");
|
||||
});
|
||||
|
||||
it("uses token:ref instead of token:missing in verbose mode", async () => {
|
||||
mockStore.profiles = {
|
||||
"github-copilot:default": {
|
||||
type: "token",
|
||||
provider: "github-copilot",
|
||||
tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" },
|
||||
},
|
||||
};
|
||||
mockOrder = ["github-copilot:default"];
|
||||
|
||||
const result = await resolveAuthLabel(
|
||||
"github-copilot",
|
||||
{} as OpenClawConfig,
|
||||
"/tmp/models.json",
|
||||
undefined,
|
||||
"verbose",
|
||||
);
|
||||
const result = await resolveRefOnlyAuthLabel({
|
||||
provider: "github-copilot",
|
||||
profileId: "github-copilot:default",
|
||||
profile: githubCopilotTokenRefProfile,
|
||||
mode: "verbose",
|
||||
});
|
||||
|
||||
expect(result.label).toContain("github-copilot:default=token:ref");
|
||||
expect(result.label).not.toContain("token:missing");
|
||||
|
||||
@ -33,6 +33,22 @@ function resolveStoredCredentialLabel(params: {
|
||||
return "missing";
|
||||
}
|
||||
|
||||
function formatExpirationLabel(
|
||||
expires: unknown,
|
||||
now: number,
|
||||
formatUntil: (timestampMs: number) => string,
|
||||
compactExpiredPrefix = " expired",
|
||||
) {
|
||||
if (typeof expires !== "number" || !Number.isFinite(expires) || expires <= 0) {
|
||||
return "";
|
||||
}
|
||||
return expires <= now ? compactExpiredPrefix : ` exp ${formatUntil(expires)}`;
|
||||
}
|
||||
|
||||
function formatFlagsSuffix(flags: string[]) {
|
||||
return flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
||||
}
|
||||
|
||||
export const resolveAuthLabel = async (
|
||||
provider: string,
|
||||
cfg: OpenClawConfig,
|
||||
@ -89,14 +105,7 @@ export const resolveAuthLabel = async (
|
||||
refValue: profile.tokenRef,
|
||||
mode,
|
||||
});
|
||||
const exp =
|
||||
typeof profile.expires === "number" &&
|
||||
Number.isFinite(profile.expires) &&
|
||||
profile.expires > 0
|
||||
? profile.expires <= now
|
||||
? " expired"
|
||||
: ` exp ${formatUntil(profile.expires)}`
|
||||
: "";
|
||||
const exp = formatExpirationLabel(profile.expires, now, formatUntil);
|
||||
return {
|
||||
label: `${profileId} token ${tokenLabel}${exp}${more}`,
|
||||
source: "",
|
||||
@ -104,14 +113,7 @@ export const resolveAuthLabel = async (
|
||||
}
|
||||
const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
|
||||
const label = display === profileId ? profileId : display;
|
||||
const exp =
|
||||
typeof profile.expires === "number" &&
|
||||
Number.isFinite(profile.expires) &&
|
||||
profile.expires > 0
|
||||
? profile.expires <= now
|
||||
? " expired"
|
||||
: ` exp ${formatUntil(profile.expires)}`
|
||||
: "";
|
||||
const exp = formatExpirationLabel(profile.expires, now, formatUntil);
|
||||
return { label: `${label} oauth${exp}${more}`, source: "" };
|
||||
}
|
||||
|
||||
@ -140,7 +142,7 @@ export const resolveAuthLabel = async (
|
||||
configProfile.mode !== profile.type &&
|
||||
!(configProfile.mode === "oauth" && profile.type === "token"))
|
||||
) {
|
||||
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
||||
const suffix = formatFlagsSuffix(flags);
|
||||
return `${profileId}=missing${suffix}`;
|
||||
}
|
||||
if (profile.type === "api_key") {
|
||||
@ -149,7 +151,7 @@ export const resolveAuthLabel = async (
|
||||
refValue: profile.keyRef,
|
||||
mode,
|
||||
});
|
||||
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
||||
const suffix = formatFlagsSuffix(flags);
|
||||
return `${profileId}=${keyLabel}${suffix}`;
|
||||
}
|
||||
if (profile.type === "token") {
|
||||
@ -158,14 +160,11 @@ export const resolveAuthLabel = async (
|
||||
refValue: profile.tokenRef,
|
||||
mode,
|
||||
});
|
||||
if (
|
||||
typeof profile.expires === "number" &&
|
||||
Number.isFinite(profile.expires) &&
|
||||
profile.expires > 0
|
||||
) {
|
||||
flags.push(profile.expires <= now ? "expired" : `exp ${formatUntil(profile.expires)}`);
|
||||
const expirationFlag = formatExpirationLabel(profile.expires, now, formatUntil, "expired");
|
||||
if (expirationFlag) {
|
||||
flags.push(expirationFlag);
|
||||
}
|
||||
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
||||
const suffix = formatFlagsSuffix(flags);
|
||||
return `${profileId}=token:${tokenLabel}${suffix}`;
|
||||
}
|
||||
const display = resolveAuthProfileDisplayLabel({
|
||||
@ -179,15 +178,12 @@ export const resolveAuthLabel = async (
|
||||
: display.startsWith(profileId)
|
||||
? display.slice(profileId.length).trim()
|
||||
: `(${display})`;
|
||||
if (
|
||||
typeof profile.expires === "number" &&
|
||||
Number.isFinite(profile.expires) &&
|
||||
profile.expires > 0
|
||||
) {
|
||||
flags.push(profile.expires <= now ? "expired" : `exp ${formatUntil(profile.expires)}`);
|
||||
const expirationFlag = formatExpirationLabel(profile.expires, now, formatUntil, "expired");
|
||||
if (expirationFlag) {
|
||||
flags.push(expirationFlag);
|
||||
}
|
||||
const suffixLabel = suffix ? ` ${suffix}` : "";
|
||||
const suffixFlags = flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
||||
const suffixFlags = formatFlagsSuffix(flags);
|
||||
return `${profileId}=OAuth${suffixLabel}${suffixFlags}`;
|
||||
});
|
||||
return {
|
||||
|
||||
@ -57,24 +57,28 @@ function resolveModelSelectionForCommand(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveModelInfoReply(
|
||||
overrides: Partial<Parameters<typeof maybeHandleModelDirectiveInfo>[0]> = {},
|
||||
) {
|
||||
return maybeHandleModelDirectiveInfo({
|
||||
directives: parseInlineDirectives("/model"),
|
||||
cfg: baseConfig(),
|
||||
agentDir: "/tmp/agent",
|
||||
activeAgentId: "main",
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-opus-4-5",
|
||||
aliasIndex: baseAliasIndex(),
|
||||
allowedModelCatalog: [],
|
||||
resetModelOverride: false,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("/model chat UX", () => {
|
||||
it("shows summary for /model with no args", async () => {
|
||||
const directives = parseInlineDirectives("/model");
|
||||
const cfg = { commands: { text: true } } as unknown as OpenClawConfig;
|
||||
|
||||
const reply = await maybeHandleModelDirectiveInfo({
|
||||
directives,
|
||||
cfg,
|
||||
agentDir: "/tmp/agent",
|
||||
activeAgentId: "main",
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-opus-4-5",
|
||||
aliasIndex: baseAliasIndex(),
|
||||
allowedModelCatalog: [],
|
||||
resetModelOverride: false,
|
||||
});
|
||||
const reply = await resolveModelInfoReply();
|
||||
|
||||
expect(reply?.text).toContain("Current:");
|
||||
expect(reply?.text).toContain("Browse: /models");
|
||||
@ -82,21 +86,11 @@ describe("/model chat UX", () => {
|
||||
});
|
||||
|
||||
it("shows active runtime model when different from selected model", async () => {
|
||||
const directives = parseInlineDirectives("/model");
|
||||
const cfg = { commands: { text: true } } as unknown as OpenClawConfig;
|
||||
|
||||
const reply = await maybeHandleModelDirectiveInfo({
|
||||
directives,
|
||||
cfg,
|
||||
agentDir: "/tmp/agent",
|
||||
activeAgentId: "main",
|
||||
const reply = await resolveModelInfoReply({
|
||||
provider: "fireworks",
|
||||
model: "fireworks/minimax-m2p5",
|
||||
defaultProvider: "fireworks",
|
||||
defaultModel: "fireworks/minimax-m2p5",
|
||||
aliasIndex: baseAliasIndex(),
|
||||
allowedModelCatalog: [],
|
||||
resetModelOverride: false,
|
||||
sessionEntry: {
|
||||
modelProvider: "deepinfra",
|
||||
model: "moonshotai/Kimi-K2.5",
|
||||
|
||||
15
src/auto-reply/reply/discord-parent-channel.ts
Normal file
15
src/auto-reply/reply/discord-parent-channel.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { normalizeConversationText } from "../../acp/conversation-id.js";
|
||||
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
|
||||
export function parseDiscordParentChannelFromSessionKey(raw: unknown): string | undefined {
|
||||
const sessionKey = normalizeConversationText(raw);
|
||||
if (!sessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
const scoped = parseAgentSessionKey(sessionKey)?.rest ?? sessionKey.toLowerCase();
|
||||
const match = scoped.match(/(?:^|:)channel:([^:]+)$/);
|
||||
if (!match?.[1]) {
|
||||
return undefined;
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
@ -4,7 +4,7 @@ import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadSessionStore, saveSessionStore, type SessionEntry } from "../../config/sessions.js";
|
||||
import type { FollowupRun } from "./queue.js";
|
||||
import { createMockTypingController } from "./test-helpers.js";
|
||||
import { createMockFollowupRun, createMockTypingController } from "./test-helpers.js";
|
||||
|
||||
const runEmbeddedPiAgentMock = vi.fn();
|
||||
const routeReplyMock = vi.fn();
|
||||
@ -50,47 +50,12 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun =>
|
||||
({
|
||||
prompt: "hello",
|
||||
summaryLine: "hello",
|
||||
enqueuedAt: Date.now(),
|
||||
originatingTo: "channel:C1",
|
||||
run: {
|
||||
sessionId: "session",
|
||||
sessionKey: "main",
|
||||
messageProvider,
|
||||
agentAccountId: "primary",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
config: {},
|
||||
skillsSnapshot: {},
|
||||
provider: "anthropic",
|
||||
model: "claude",
|
||||
thinkLevel: "low",
|
||||
verboseLevel: "off",
|
||||
elevatedLevel: "off",
|
||||
bashElevated: {
|
||||
enabled: false,
|
||||
allowed: false,
|
||||
defaultLevel: "off",
|
||||
},
|
||||
timeoutMs: 1_000,
|
||||
blockReplyBreak: "message_end",
|
||||
},
|
||||
}) as FollowupRun;
|
||||
createMockFollowupRun({ run: { messageProvider } });
|
||||
|
||||
function createQueuedRun(
|
||||
overrides: Partial<Omit<FollowupRun, "run">> & { run?: Partial<FollowupRun["run"]> } = {},
|
||||
): FollowupRun {
|
||||
const base = baseQueuedRun();
|
||||
return {
|
||||
...base,
|
||||
...overrides,
|
||||
run: {
|
||||
...base.run,
|
||||
...overrides.run,
|
||||
},
|
||||
};
|
||||
return createMockFollowupRun(overrides);
|
||||
}
|
||||
|
||||
function mockCompactionRun(params: {
|
||||
|
||||
@ -84,6 +84,19 @@ const createHandleInlineActionsInput = (params: {
|
||||
};
|
||||
};
|
||||
|
||||
async function expectInlineActionSkipped(params: {
|
||||
ctx: ReturnType<typeof buildTestCtx>;
|
||||
typing: TypingController;
|
||||
cleanedBody: string;
|
||||
command?: Partial<HandleInlineActionsInput["command"]>;
|
||||
overrides?: Partial<Omit<HandleInlineActionsInput, "ctx" | "sessionCtx" | "typing" | "command">>;
|
||||
}) {
|
||||
const result = await handleInlineActions(createHandleInlineActionsInput(params));
|
||||
expect(result).toEqual({ kind: "reply", reply: undefined });
|
||||
expect(params.typing.cleanup).toHaveBeenCalled();
|
||||
expect(handleCommandsMock).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
describe("handleInlineActions", () => {
|
||||
beforeEach(() => {
|
||||
handleCommandsMock.mockReset();
|
||||
@ -97,18 +110,12 @@ describe("handleInlineActions", () => {
|
||||
To: "whatsapp:+123",
|
||||
Body: "hi",
|
||||
});
|
||||
const result = await handleInlineActions(
|
||||
createHandleInlineActionsInput({
|
||||
ctx,
|
||||
typing,
|
||||
cleanedBody: "hi",
|
||||
command: { to: "whatsapp:+123" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ kind: "reply", reply: undefined });
|
||||
expect(typing.cleanup).toHaveBeenCalled();
|
||||
expect(handleCommandsMock).not.toHaveBeenCalled();
|
||||
await expectInlineActionSkipped({
|
||||
ctx,
|
||||
typing,
|
||||
cleanedBody: "hi",
|
||||
command: { to: "whatsapp:+123" },
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards agentDir into handleCommands", async () => {
|
||||
@ -163,25 +170,19 @@ describe("handleInlineActions", () => {
|
||||
MessageSid: "41",
|
||||
});
|
||||
|
||||
const result = await handleInlineActions(
|
||||
createHandleInlineActionsInput({
|
||||
ctx,
|
||||
typing,
|
||||
cleanedBody: "old queued message",
|
||||
command: {
|
||||
rawBodyNormalized: "old queued message",
|
||||
commandBodyNormalized: "old queued message",
|
||||
},
|
||||
overrides: {
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ kind: "reply", reply: undefined });
|
||||
expect(typing.cleanup).toHaveBeenCalled();
|
||||
expect(handleCommandsMock).not.toHaveBeenCalled();
|
||||
await expectInlineActionSkipped({
|
||||
ctx,
|
||||
typing,
|
||||
cleanedBody: "old queued message",
|
||||
command: {
|
||||
rawBodyNormalized: "old queued message",
|
||||
commandBodyNormalized: "old queued message",
|
||||
},
|
||||
overrides: {
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("clears /stop cutoff when a newer message arrives", async () => {
|
||||
|
||||
@ -15,6 +15,28 @@ describe("readPostCompactionContext", () => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function expectLegacySectionFallback(
|
||||
postCompactionSections: string[],
|
||||
expectDefaultProse = false,
|
||||
) {
|
||||
const content = `## Every Session\n\nDo startup things.\n\n## Safety\n\nBe safe.\n`;
|
||||
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: { postCompactionSections },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const result = await readPostCompactionContext(tmpDir, cfg);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toContain("Do startup things");
|
||||
expect(result).toContain("Be safe");
|
||||
if (expectDefaultProse) {
|
||||
expect(result).toContain("Run your Session Startup sequence");
|
||||
}
|
||||
}
|
||||
|
||||
it("returns null when no AGENTS.md exists", async () => {
|
||||
const result = await readPostCompactionContext(tmpDir);
|
||||
expect(result).toBeNull();
|
||||
@ -339,36 +361,11 @@ Read WORKFLOW.md on startup.
|
||||
// Older AGENTS.md templates use "Every Session" / "Safety" instead of
|
||||
// "Session Startup" / "Red Lines". Explicitly setting the defaults should
|
||||
// still trigger the legacy fallback — same behavior as leaving the field unset.
|
||||
const content = `## Every Session\n\nDo startup things.\n\n## Safety\n\nBe safe.\n`;
|
||||
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: { postCompactionSections: ["Session Startup", "Red Lines"] },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const result = await readPostCompactionContext(tmpDir, cfg);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toContain("Do startup things");
|
||||
expect(result).toContain("Be safe");
|
||||
await expectLegacySectionFallback(["Session Startup", "Red Lines"]);
|
||||
});
|
||||
|
||||
it("falls back to legacy sections when default sections are configured in a different order", async () => {
|
||||
const content = `## Every Session\n\nDo startup things.\n\n## Safety\n\nBe safe.\n`;
|
||||
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: { postCompactionSections: ["Red Lines", "Session Startup"] },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const result = await readPostCompactionContext(tmpDir, cfg);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toContain("Do startup things");
|
||||
expect(result).toContain("Be safe");
|
||||
expect(result).toContain("Run your Session Startup sequence");
|
||||
await expectLegacySectionFallback(["Red Lines", "Session Startup"], true);
|
||||
});
|
||||
|
||||
it("custom section names are matched case-insensitively", async () => {
|
||||
|
||||
@ -27,68 +27,65 @@ function buildContext(overrides?: Partial<MsgContext>): MsgContext {
|
||||
} as MsgContext;
|
||||
}
|
||||
|
||||
function expectAllowFromDecision(params: {
|
||||
allowFrom: string[];
|
||||
ctx?: Partial<MsgContext>;
|
||||
allowed: boolean;
|
||||
}) {
|
||||
const result = resolveElevatedPermissions({
|
||||
cfg: buildConfig(params.allowFrom),
|
||||
agentId: "main",
|
||||
provider: "whatsapp",
|
||||
ctx: buildContext(params.ctx),
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.allowed).toBe(params.allowed);
|
||||
if (params.allowed) {
|
||||
expect(result.failures).toHaveLength(0);
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.failures).toContainEqual({
|
||||
gate: "allowFrom",
|
||||
key: "tools.elevated.allowFrom.whatsapp",
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveElevatedPermissions", () => {
|
||||
it("authorizes when sender matches allowFrom", () => {
|
||||
const result = resolveElevatedPermissions({
|
||||
cfg: buildConfig(["+15550001111"]),
|
||||
agentId: "main",
|
||||
provider: "whatsapp",
|
||||
ctx: buildContext(),
|
||||
expectAllowFromDecision({
|
||||
allowFrom: ["+15550001111"],
|
||||
allowed: true,
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.failures).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not authorize when only recipient matches allowFrom", () => {
|
||||
const result = resolveElevatedPermissions({
|
||||
cfg: buildConfig(["+15559990000"]),
|
||||
agentId: "main",
|
||||
provider: "whatsapp",
|
||||
ctx: buildContext(),
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.failures).toContainEqual({
|
||||
gate: "allowFrom",
|
||||
key: "tools.elevated.allowFrom.whatsapp",
|
||||
expectAllowFromDecision({
|
||||
allowFrom: ["+15559990000"],
|
||||
allowed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not authorize untyped mutable sender fields", () => {
|
||||
const result = resolveElevatedPermissions({
|
||||
cfg: buildConfig(["owner-display-name"]),
|
||||
agentId: "main",
|
||||
provider: "whatsapp",
|
||||
ctx: buildContext({
|
||||
expectAllowFromDecision({
|
||||
allowFrom: ["owner-display-name"],
|
||||
allowed: false,
|
||||
ctx: {
|
||||
SenderName: "owner-display-name",
|
||||
SenderUsername: "owner-display-name",
|
||||
SenderTag: "owner-display-name",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.failures).toContainEqual({
|
||||
gate: "allowFrom",
|
||||
key: "tools.elevated.allowFrom.whatsapp",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("authorizes mutable sender fields only with explicit prefix", () => {
|
||||
const result = resolveElevatedPermissions({
|
||||
cfg: buildConfig(["username:owner_username"]),
|
||||
agentId: "main",
|
||||
provider: "whatsapp",
|
||||
ctx: buildContext({
|
||||
expectAllowFromDecision({
|
||||
allowFrom: ["username:owner_username"],
|
||||
allowed: true,
|
||||
ctx: {
|
||||
SenderUsername: "owner_username",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.failures).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@ -105,6 +105,23 @@ const createMSTeamsPlugin = (params: { outbound: ChannelOutboundAdapter }): Chan
|
||||
outbound: params.outbound,
|
||||
});
|
||||
|
||||
async function expectSlackNoSend(
|
||||
payload: Parameters<typeof routeReply>[0]["payload"],
|
||||
overrides: Partial<Parameters<typeof routeReply>[0]> = {},
|
||||
) {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
const res = await routeReply({
|
||||
payload,
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
cfg: {} as never,
|
||||
...overrides,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
|
||||
return res;
|
||||
}
|
||||
|
||||
describe("routeReply", () => {
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(defaultRegistry);
|
||||
@ -132,39 +149,15 @@ describe("routeReply", () => {
|
||||
});
|
||||
|
||||
it("no-ops on empty payload", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
const res = await routeReply({
|
||||
payload: {},
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
|
||||
await expectSlackNoSend({});
|
||||
});
|
||||
|
||||
it("suppresses reasoning payloads", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
const res = await routeReply({
|
||||
payload: { text: "Reasoning:\n_step_", isReasoning: true },
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
|
||||
await expectSlackNoSend({ text: "Reasoning:\n_step_", isReasoning: true });
|
||||
});
|
||||
|
||||
it("drops silent token payloads", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
const res = await routeReply({
|
||||
payload: { text: SILENT_REPLY_TOKEN },
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
|
||||
await expectSlackNoSend({ text: SILENT_REPLY_TOKEN });
|
||||
});
|
||||
|
||||
it("does not drop payloads that merely start with the silent token", async () => {
|
||||
@ -231,23 +224,14 @@ describe("routeReply", () => {
|
||||
});
|
||||
|
||||
it("does not bypass the empty-reply guard for invalid Slack blocks", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
const res = await routeReply({
|
||||
payload: {
|
||||
text: " ",
|
||||
channelData: {
|
||||
slack: {
|
||||
blocks: " ",
|
||||
},
|
||||
await expectSlackNoSend({
|
||||
text: " ",
|
||||
channelData: {
|
||||
slack: {
|
||||
blocks: " ",
|
||||
},
|
||||
},
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
cfg: {} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not derive responsePrefix from agent identity when routing", async () => {
|
||||
|
||||
@ -117,6 +117,27 @@ export async function drainFormattedSystemEvents(params: {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
async function persistSessionEntryUpdate(params: {
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
nextEntry: SessionEntry;
|
||||
}) {
|
||||
if (!params.sessionStore || !params.sessionKey) {
|
||||
return;
|
||||
}
|
||||
params.sessionStore[params.sessionKey] = {
|
||||
...params.sessionStore[params.sessionKey],
|
||||
...params.nextEntry,
|
||||
};
|
||||
if (!params.storePath) {
|
||||
return;
|
||||
}
|
||||
await updateSessionStore(params.storePath, (store) => {
|
||||
store[params.sessionKey!] = { ...store[params.sessionKey!], ...params.nextEntry };
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureSkillSnapshot(params: {
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
@ -185,12 +206,7 @@ export async function ensureSkillSnapshot(params: {
|
||||
systemSent: true,
|
||||
skillsSnapshot: skillSnapshot,
|
||||
};
|
||||
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry };
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = { ...store[sessionKey], ...nextEntry };
|
||||
});
|
||||
}
|
||||
await persistSessionEntryUpdate({ sessionStore, sessionKey, storePath, nextEntry });
|
||||
systemSent = true;
|
||||
}
|
||||
|
||||
@ -227,12 +243,7 @@ export async function ensureSkillSnapshot(params: {
|
||||
updatedAt: Date.now(),
|
||||
skillsSnapshot,
|
||||
};
|
||||
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry };
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = { ...store[sessionKey], ...nextEntry };
|
||||
});
|
||||
}
|
||||
await persistSessionEntryUpdate({ sessionStore, sessionKey, storePath, nextEntry });
|
||||
}
|
||||
|
||||
return { sessionEntry: nextEntry, skillsSnapshot, systemSent };
|
||||
|
||||
@ -34,11 +34,12 @@ import { resolveConversationIdFromTargets } from "../../infra/outbound/conversat
|
||||
import { deliverSessionMaintenanceWarning } from "../../infra/session-maintenance-warning.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import { normalizeMainKey, parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { normalizeMainKey } from "../../routing/session-key.js";
|
||||
import { normalizeSessionDeliveryFields } from "../../utils/delivery-context.js";
|
||||
import { resolveCommandAuthorization } from "../command-auth.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
import { resolveEffectiveResetTargetSessionKey } from "./acp-reset-target.js";
|
||||
import { parseDiscordParentChannelFromSessionKey } from "./discord-parent-channel.js";
|
||||
import { normalizeInboundTextNewlines } from "./inbound-text.js";
|
||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||
import {
|
||||
@ -70,19 +71,6 @@ export type SessionInitResult = {
|
||||
triggerBodyNormalized: string;
|
||||
};
|
||||
|
||||
function parseDiscordParentChannelFromSessionKey(raw: unknown): string | undefined {
|
||||
const sessionKey = normalizeConversationText(raw);
|
||||
if (!sessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
const scoped = parseAgentSessionKey(sessionKey)?.rest ?? sessionKey.toLowerCase();
|
||||
const match = scoped.match(/(?:^|:)channel:([^:]+)$/);
|
||||
if (!match?.[1]) {
|
||||
return undefined;
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function resolveAcpResetBindingContext(ctx: MsgContext): {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
|
||||
@ -7,7 +7,7 @@ import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { copyFileWithinRoot, SafeOpenError } from "../../infra/fs-safe.js";
|
||||
import { normalizeScpRemoteHost } from "../../infra/scp-host.js";
|
||||
import { normalizeScpRemoteHost, normalizeScpRemotePath } from "../../infra/scp-host.js";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
|
||||
import {
|
||||
isInboundPathAllowed,
|
||||
@ -293,6 +293,10 @@ async function scpFile(remoteHost: string, remotePath: string, localPath: string
|
||||
if (!safeRemoteHost) {
|
||||
throw new Error("invalid remote host for SCP");
|
||||
}
|
||||
const safeRemotePath = normalizeScpRemotePath(remotePath);
|
||||
if (!safeRemotePath) {
|
||||
throw new Error("invalid remote path for SCP");
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
"/usr/bin/scp",
|
||||
@ -302,7 +306,7 @@ async function scpFile(remoteHost: string, remotePath: string, localPath: string
|
||||
"-o",
|
||||
"StrictHostKeyChecking=yes",
|
||||
"--",
|
||||
`${safeRemoteHost}:${remotePath}`,
|
||||
`${safeRemoteHost}:${safeRemotePath}`,
|
||||
localPath,
|
||||
],
|
||||
{ stdio: ["ignore", "ignore", "pipe"] },
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { vi } from "vitest";
|
||||
import type { FollowupRun } from "./queue.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
export function createMockTypingController(
|
||||
@ -16,3 +17,44 @@ export function createMockTypingController(
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMockFollowupRun(
|
||||
overrides: Partial<Omit<FollowupRun, "run">> & { run?: Partial<FollowupRun["run"]> } = {},
|
||||
): FollowupRun {
|
||||
const base: FollowupRun = {
|
||||
prompt: "hello",
|
||||
summaryLine: "hello",
|
||||
enqueuedAt: Date.now(),
|
||||
originatingTo: "channel:C1",
|
||||
run: {
|
||||
sessionId: "session",
|
||||
sessionKey: "main",
|
||||
messageProvider: "whatsapp",
|
||||
agentAccountId: "primary",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
config: {},
|
||||
skillsSnapshot: {},
|
||||
provider: "anthropic",
|
||||
model: "claude",
|
||||
thinkLevel: "low",
|
||||
verboseLevel: "off",
|
||||
elevatedLevel: "off",
|
||||
bashElevated: {
|
||||
enabled: false,
|
||||
allowed: false,
|
||||
defaultLevel: "off",
|
||||
},
|
||||
timeoutMs: 1_000,
|
||||
blockReplyBreak: "message_end",
|
||||
},
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
...overrides,
|
||||
run: {
|
||||
...base.run,
|
||||
...overrides.run,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -129,4 +129,64 @@ describe("chrome MCP page parsing", () => {
|
||||
|
||||
expect(result).toBe(123);
|
||||
});
|
||||
|
||||
it("surfaces MCP tool errors instead of JSON parse noise", async () => {
|
||||
const factory: ChromeMcpSessionFactory = async () => {
|
||||
const session = createFakeSession();
|
||||
const callTool = vi.fn(async ({ name }: ToolCall) => {
|
||||
if (name === "evaluate_script") {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Cannot read properties of null (reading 'value')",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected tool ${name}`);
|
||||
});
|
||||
session.client.callTool = callTool as typeof session.client.callTool;
|
||||
return session;
|
||||
};
|
||||
setChromeMcpSessionFactoryForTest(factory);
|
||||
|
||||
await expect(
|
||||
evaluateChromeMcpScript({
|
||||
profileName: "chrome-live",
|
||||
targetId: "1",
|
||||
fn: "() => document.getElementById('missing').value",
|
||||
}),
|
||||
).rejects.toThrow(/Cannot read properties of null/);
|
||||
});
|
||||
|
||||
it("reuses a single pending session for concurrent requests", async () => {
|
||||
let factoryCalls = 0;
|
||||
let releaseFactory!: () => void;
|
||||
const factoryGate = new Promise<void>((resolve) => {
|
||||
releaseFactory = resolve;
|
||||
});
|
||||
|
||||
const factory: ChromeMcpSessionFactory = async () => {
|
||||
factoryCalls += 1;
|
||||
await factoryGate;
|
||||
return createFakeSession();
|
||||
};
|
||||
setChromeMcpSessionFactoryForTest(factory);
|
||||
|
||||
const tabsPromise = listChromeMcpTabs("chrome-live");
|
||||
const evalPromise = evaluateChromeMcpScript({
|
||||
profileName: "chrome-live",
|
||||
targetId: "1",
|
||||
fn: "() => 123",
|
||||
});
|
||||
|
||||
releaseFactory();
|
||||
const [tabs, result] = await Promise.all([tabsPromise, evalPromise]);
|
||||
|
||||
expect(factoryCalls).toBe(1);
|
||||
expect(tabs).toHaveLength(2);
|
||||
expect(result).toBe(123);
|
||||
});
|
||||
});
|
||||
|
||||
@ -39,6 +39,7 @@ const DEFAULT_CHROME_MCP_ARGS = [
|
||||
];
|
||||
|
||||
const sessions = new Map<string, ChromeMcpSession>();
|
||||
const pendingSessions = new Map<string, Promise<ChromeMcpSession>>();
|
||||
let sessionFactory: ChromeMcpSessionFactory | null = null;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
@ -144,6 +145,11 @@ function extractMessageText(result: ChromeMcpToolResult): string {
|
||||
return blocks.find((block) => block.trim()) ?? "";
|
||||
}
|
||||
|
||||
function extractToolErrorMessage(result: ChromeMcpToolResult, name: string): string {
|
||||
const message = extractMessageText(result).trim();
|
||||
return message || `Chrome MCP tool "${name}" failed.`;
|
||||
}
|
||||
|
||||
function extractJsonMessage(result: ChromeMcpToolResult): unknown {
|
||||
const candidates = [extractMessageText(result), ...extractTextContent(result)].filter((text) =>
|
||||
text.trim(),
|
||||
@ -207,8 +213,22 @@ async function getSession(profileName: string): Promise<ChromeMcpSession> {
|
||||
session = undefined;
|
||||
}
|
||||
if (!session) {
|
||||
session = await (sessionFactory ?? createRealSession)(profileName);
|
||||
sessions.set(profileName, session);
|
||||
let pending = pendingSessions.get(profileName);
|
||||
if (!pending) {
|
||||
pending = (async () => {
|
||||
const created = await (sessionFactory ?? createRealSession)(profileName);
|
||||
sessions.set(profileName, created);
|
||||
return created;
|
||||
})();
|
||||
pendingSessions.set(profileName, pending);
|
||||
}
|
||||
try {
|
||||
session = await pending;
|
||||
} finally {
|
||||
if (pendingSessions.get(profileName) === pending) {
|
||||
pendingSessions.delete(profileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await session.ready;
|
||||
@ -229,10 +249,14 @@ async function callTool(
|
||||
): Promise<ChromeMcpToolResult> {
|
||||
const session = await getSession(profileName);
|
||||
try {
|
||||
return (await session.client.callTool({
|
||||
const result = (await session.client.callTool({
|
||||
name,
|
||||
arguments: args,
|
||||
})) as ChromeMcpToolResult;
|
||||
if (result.isError) {
|
||||
throw new Error(extractToolErrorMessage(result, name));
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
sessions.delete(profileName);
|
||||
await session.client.close().catch(() => {});
|
||||
@ -268,6 +292,7 @@ export function getChromeMcpPid(profileName: string): number | null {
|
||||
}
|
||||
|
||||
export async function closeChromeMcpSession(profileName: string): Promise<boolean> {
|
||||
pendingSessions.delete(profileName);
|
||||
const session = sessions.get(profileName);
|
||||
if (!session) {
|
||||
return false;
|
||||
@ -508,5 +533,6 @@ export function setChromeMcpSessionFactoryForTest(factory: ChromeMcpSessionFacto
|
||||
|
||||
export async function resetChromeMcpSessionsForTest(): Promise<void> {
|
||||
sessionFactory = null;
|
||||
pendingSessions.clear();
|
||||
await stopAllChromeMcpSessions();
|
||||
}
|
||||
|
||||
@ -1,22 +1,7 @@
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { ensurePageState, getPageForTargetId } from "./pw-session.js";
|
||||
import { normalizeTimeoutMs } from "./pw-tools-core.shared.js";
|
||||
|
||||
function matchUrlPattern(pattern: string, url: string): boolean {
|
||||
const p = pattern.trim();
|
||||
if (!p) {
|
||||
return false;
|
||||
}
|
||||
if (p === url) {
|
||||
return true;
|
||||
}
|
||||
if (p.includes("*")) {
|
||||
const escaped = p.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
||||
const regex = new RegExp(`^${escaped.replace(/\*\*/g, ".*").replace(/\*/g, ".*")}$`);
|
||||
return regex.test(url);
|
||||
}
|
||||
return url.includes(p);
|
||||
}
|
||||
import { matchBrowserUrlPattern } from "./url-pattern.js";
|
||||
|
||||
export async function responseBodyViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
@ -65,7 +50,7 @@ export async function responseBodyViaPlaywright(opts: {
|
||||
}
|
||||
const r = resp as { url?: () => string };
|
||||
const u = r.url?.() || "";
|
||||
if (!matchUrlPattern(pattern, u)) {
|
||||
if (!matchBrowserUrlPattern(pattern, u)) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
|
||||
@ -291,6 +291,6 @@ describe("pw-tools-core", () => {
|
||||
targetId: "T1",
|
||||
ref: " ",
|
||||
}),
|
||||
).rejects.toThrow(/ref is required/i);
|
||||
).rejects.toThrow(/ref or selector is required/i);
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
import type { BrowserActRequest, BrowserFormField } from "../client-actions-core.js";
|
||||
import { normalizeBrowserFormField } from "../form-fields.js";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { matchBrowserUrlPattern } from "../url-pattern.js";
|
||||
import { registerBrowserAgentActDownloadRoutes } from "./agent.act.download.js";
|
||||
import { registerBrowserAgentActHookRoutes } from "./agent.act.hooks.js";
|
||||
import {
|
||||
@ -47,7 +48,6 @@ function buildExistingSessionWaitPredicate(params: {
|
||||
text?: string;
|
||||
textGone?: string;
|
||||
selector?: string;
|
||||
url?: string;
|
||||
loadState?: "load" | "domcontentloaded" | "networkidle";
|
||||
fn?: string;
|
||||
}): string | null {
|
||||
@ -61,9 +61,6 @@ function buildExistingSessionWaitPredicate(params: {
|
||||
if (params.selector) {
|
||||
checks.push(`Boolean(document.querySelector(${JSON.stringify(params.selector)}))`);
|
||||
}
|
||||
if (params.url) {
|
||||
checks.push(`window.location.href === ${JSON.stringify(params.url)}`);
|
||||
}
|
||||
if (params.loadState === "domcontentloaded") {
|
||||
checks.push(`document.readyState === "interactive" || document.readyState === "complete"`);
|
||||
} else if (params.loadState === "load") {
|
||||
@ -94,17 +91,30 @@ async function waitForExistingSessionCondition(params: {
|
||||
await sleep(params.timeMs);
|
||||
}
|
||||
const predicate = buildExistingSessionWaitPredicate(params);
|
||||
if (!predicate) {
|
||||
if (!predicate && !params.url) {
|
||||
return;
|
||||
}
|
||||
const timeoutMs = Math.max(250, params.timeoutMs ?? 10_000);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const ready = await evaluateChromeMcpScript({
|
||||
profileName: params.profileName,
|
||||
targetId: params.targetId,
|
||||
fn: `async () => ${predicate}`,
|
||||
});
|
||||
let ready = true;
|
||||
if (predicate) {
|
||||
ready = Boolean(
|
||||
await evaluateChromeMcpScript({
|
||||
profileName: params.profileName,
|
||||
targetId: params.targetId,
|
||||
fn: `async () => ${predicate}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (ready && params.url) {
|
||||
const currentUrl = await evaluateChromeMcpScript({
|
||||
profileName: params.profileName,
|
||||
targetId: params.targetId,
|
||||
fn: "() => window.location.href",
|
||||
});
|
||||
ready = typeof currentUrl === "string" && matchBrowserUrlPattern(params.url, currentUrl);
|
||||
}
|
||||
if (ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -195,4 +195,37 @@ describe("existing-session browser routes", () => {
|
||||
});
|
||||
expect(chromeMcpMocks.evaluateChromeMcpScript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("supports glob URL waits for existing-session profiles", async () => {
|
||||
chromeMcpMocks.evaluateChromeMcpScript.mockReset();
|
||||
chromeMcpMocks.evaluateChromeMcpScript.mockImplementation(
|
||||
async ({ fn }: { fn: string }) =>
|
||||
(fn === "() => window.location.href" ? "https://example.com/" : true) as never,
|
||||
);
|
||||
|
||||
const { app, postHandlers } = createApp();
|
||||
registerBrowserAgentActRoutes(app, {
|
||||
state: () => ({ resolved: { evaluateEnabled: true } }),
|
||||
} as never);
|
||||
const handler = postHandlers.get("/act");
|
||||
expect(handler).toBeTypeOf("function");
|
||||
|
||||
const response = createResponse();
|
||||
await handler?.(
|
||||
{
|
||||
params: {},
|
||||
query: {},
|
||||
body: { kind: "wait", url: "**/example.com/" },
|
||||
},
|
||||
response.res,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toMatchObject({ ok: true, targetId: "7" });
|
||||
expect(chromeMcpMocks.evaluateChromeMcpScript).toHaveBeenCalledWith({
|
||||
profileName: "chrome-live",
|
||||
targetId: "7",
|
||||
fn: "() => window.location.href",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
80
src/browser/routes/basic.existing-session.test.ts
Normal file
80
src/browser/routes/basic.existing-session.test.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BrowserProfileUnavailableError } from "../errors.js";
|
||||
import { registerBrowserBasicRoutes } from "./basic.js";
|
||||
import type { BrowserResponse, BrowserRouteHandler, BrowserRouteRegistrar } from "./types.js";
|
||||
|
||||
function createApp() {
|
||||
const getHandlers = new Map<string, BrowserRouteHandler>();
|
||||
const postHandlers = new Map<string, BrowserRouteHandler>();
|
||||
const deleteHandlers = new Map<string, BrowserRouteHandler>();
|
||||
const app: BrowserRouteRegistrar = {
|
||||
get: (path, handler) => void getHandlers.set(path, handler),
|
||||
post: (path, handler) => void postHandlers.set(path, handler),
|
||||
delete: (path, handler) => void deleteHandlers.set(path, handler),
|
||||
};
|
||||
return { app, getHandlers };
|
||||
}
|
||||
|
||||
function createResponse() {
|
||||
let statusCode = 200;
|
||||
let jsonBody: unknown;
|
||||
const res: BrowserResponse = {
|
||||
status(code) {
|
||||
statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(body) {
|
||||
jsonBody = body;
|
||||
},
|
||||
};
|
||||
return {
|
||||
res,
|
||||
get statusCode() {
|
||||
return statusCode;
|
||||
},
|
||||
get body() {
|
||||
return jsonBody;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("basic browser routes", () => {
|
||||
it("maps existing-session status failures to JSON browser errors", async () => {
|
||||
const { app, getHandlers } = createApp();
|
||||
registerBrowserBasicRoutes(app, {
|
||||
state: () => ({
|
||||
resolved: {
|
||||
enabled: true,
|
||||
headless: false,
|
||||
noSandbox: false,
|
||||
executablePath: undefined,
|
||||
},
|
||||
profiles: new Map(),
|
||||
}),
|
||||
forProfile: () =>
|
||||
({
|
||||
profile: {
|
||||
name: "chrome-live",
|
||||
driver: "existing-session",
|
||||
cdpPort: 18802,
|
||||
cdpUrl: "http://127.0.0.1:18802",
|
||||
color: "#00AA00",
|
||||
attachOnly: true,
|
||||
},
|
||||
isHttpReachable: async () => {
|
||||
throw new BrowserProfileUnavailableError("attach failed");
|
||||
},
|
||||
isReachable: async () => true,
|
||||
}) as never,
|
||||
} as never);
|
||||
|
||||
const handler = getHandlers.get("/");
|
||||
expect(handler).toBeTypeOf("function");
|
||||
|
||||
const response = createResponse();
|
||||
await handler?.({ params: {}, query: { profile: "chrome-live" } }, response.res);
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.body).toMatchObject({ error: "attach failed" });
|
||||
});
|
||||
});
|
||||
@ -54,50 +54,58 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
|
||||
return jsonError(res, profileCtx.status, profileCtx.error);
|
||||
}
|
||||
|
||||
const [cdpHttp, cdpReady] = await Promise.all([
|
||||
profileCtx.isHttpReachable(300),
|
||||
profileCtx.isReachable(600),
|
||||
]);
|
||||
|
||||
const profileState = current.profiles.get(profileCtx.profile.name);
|
||||
let detectedBrowser: string | null = null;
|
||||
let detectedExecutablePath: string | null = null;
|
||||
let detectError: string | null = null;
|
||||
|
||||
try {
|
||||
const detected = resolveBrowserExecutableForPlatform(current.resolved, process.platform);
|
||||
if (detected) {
|
||||
detectedBrowser = detected.kind;
|
||||
detectedExecutablePath = detected.path;
|
||||
}
|
||||
} catch (err) {
|
||||
detectError = String(err);
|
||||
}
|
||||
const [cdpHttp, cdpReady] = await Promise.all([
|
||||
profileCtx.isHttpReachable(300),
|
||||
profileCtx.isReachable(600),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
enabled: current.resolved.enabled,
|
||||
profile: profileCtx.profile.name,
|
||||
driver: profileCtx.profile.driver,
|
||||
running: cdpReady,
|
||||
cdpReady,
|
||||
cdpHttp,
|
||||
pid:
|
||||
profileCtx.profile.driver === "existing-session"
|
||||
? getChromeMcpPid(profileCtx.profile.name)
|
||||
: (profileState?.running?.pid ?? null),
|
||||
cdpPort: profileCtx.profile.cdpPort,
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
chosenBrowser: profileState?.running?.exe.kind ?? null,
|
||||
detectedBrowser,
|
||||
detectedExecutablePath,
|
||||
detectError,
|
||||
userDataDir: profileState?.running?.userDataDir ?? null,
|
||||
color: profileCtx.profile.color,
|
||||
headless: current.resolved.headless,
|
||||
noSandbox: current.resolved.noSandbox,
|
||||
executablePath: current.resolved.executablePath ?? null,
|
||||
attachOnly: profileCtx.profile.attachOnly,
|
||||
});
|
||||
const profileState = current.profiles.get(profileCtx.profile.name);
|
||||
let detectedBrowser: string | null = null;
|
||||
let detectedExecutablePath: string | null = null;
|
||||
let detectError: string | null = null;
|
||||
|
||||
try {
|
||||
const detected = resolveBrowserExecutableForPlatform(current.resolved, process.platform);
|
||||
if (detected) {
|
||||
detectedBrowser = detected.kind;
|
||||
detectedExecutablePath = detected.path;
|
||||
}
|
||||
} catch (err) {
|
||||
detectError = String(err);
|
||||
}
|
||||
|
||||
res.json({
|
||||
enabled: current.resolved.enabled,
|
||||
profile: profileCtx.profile.name,
|
||||
driver: profileCtx.profile.driver,
|
||||
running: cdpReady,
|
||||
cdpReady,
|
||||
cdpHttp,
|
||||
pid:
|
||||
profileCtx.profile.driver === "existing-session"
|
||||
? getChromeMcpPid(profileCtx.profile.name)
|
||||
: (profileState?.running?.pid ?? null),
|
||||
cdpPort: profileCtx.profile.cdpPort,
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
chosenBrowser: profileState?.running?.exe.kind ?? null,
|
||||
detectedBrowser,
|
||||
detectedExecutablePath,
|
||||
detectError,
|
||||
userDataDir: profileState?.running?.userDataDir ?? null,
|
||||
color: profileCtx.profile.color,
|
||||
headless: current.resolved.headless,
|
||||
noSandbox: current.resolved.noSandbox,
|
||||
executablePath: current.resolved.executablePath ?? null,
|
||||
attachOnly: profileCtx.profile.attachOnly,
|
||||
});
|
||||
} catch (err) {
|
||||
const mapped = toBrowserErrorResponse(err);
|
||||
if (mapped) {
|
||||
return jsonError(res, mapped.status, mapped.message);
|
||||
}
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
// Start browser (profile-aware)
|
||||
|
||||
@ -96,10 +96,14 @@ describe("browser control server", () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind: "click", selector: "button.save" }),
|
||||
});
|
||||
expect(clickSelector.status).toBe(400);
|
||||
expect(((await clickSelector.json()) as { error?: string }).error).toMatch(
|
||||
/'selector' is not supported/i,
|
||||
);
|
||||
expect(clickSelector.status).toBe(200);
|
||||
expect(((await clickSelector.json()) as { ok?: boolean }).ok).toBe(true);
|
||||
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(2, {
|
||||
cdpUrl: state.cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
selector: "button.save",
|
||||
doubleClick: false,
|
||||
});
|
||||
|
||||
const type = await postJson<{ ok: boolean }>(`${base}/act`, {
|
||||
kind: "type",
|
||||
|
||||
15
src/browser/url-pattern.ts
Normal file
15
src/browser/url-pattern.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export function matchBrowserUrlPattern(pattern: string, url: string): boolean {
|
||||
const trimmedPattern = pattern.trim();
|
||||
if (!trimmedPattern) {
|
||||
return false;
|
||||
}
|
||||
if (trimmedPattern === url) {
|
||||
return true;
|
||||
}
|
||||
if (trimmedPattern.includes("*")) {
|
||||
const escaped = trimmedPattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
||||
const regex = new RegExp(`^${escaped.replace(/\*\*/g, ".*").replace(/\*/g, ".*")}$`);
|
||||
return regex.test(url);
|
||||
}
|
||||
return url.includes(trimmedPattern);
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { upsertAuthProfileWithLock } from "../agents/auth-profiles.js";
|
||||
import type { ApiKeyCredential, AuthProfileCredential } from "../agents/auth-profiles/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type {
|
||||
ProviderAuthResult,
|
||||
ProviderAuthMethodNonInteractiveContext,
|
||||
ProviderNonInteractiveApiKeyResult,
|
||||
} from "../plugins/types.js";
|
||||
@ -85,7 +86,7 @@ function buildOpenAICompatibleSelfHostedProviderConfig(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function promptAndConfigureOpenAICompatibleSelfHostedProvider(params: {
|
||||
type OpenAICompatibleSelfHostedProviderSetupParams = {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
providerId: string;
|
||||
@ -97,13 +98,34 @@ export async function promptAndConfigureOpenAICompatibleSelfHostedProvider(param
|
||||
reasoning?: boolean;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
}): Promise<{
|
||||
};
|
||||
|
||||
type OpenAICompatibleSelfHostedProviderPromptResult = {
|
||||
config: OpenClawConfig;
|
||||
credential: AuthProfileCredential;
|
||||
modelId: string;
|
||||
modelRef: string;
|
||||
profileId: string;
|
||||
}> {
|
||||
};
|
||||
|
||||
function buildSelfHostedProviderAuthResult(
|
||||
result: OpenAICompatibleSelfHostedProviderPromptResult,
|
||||
): ProviderAuthResult {
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId: result.profileId,
|
||||
credential: result.credential,
|
||||
},
|
||||
],
|
||||
configPatch: result.config,
|
||||
defaultModel: result.modelRef,
|
||||
};
|
||||
}
|
||||
|
||||
export async function promptAndConfigureOpenAICompatibleSelfHostedProvider(
|
||||
params: OpenAICompatibleSelfHostedProviderSetupParams,
|
||||
): Promise<OpenAICompatibleSelfHostedProviderPromptResult> {
|
||||
const baseUrlRaw = await params.prompter.text({
|
||||
message: `${params.providerLabel} base URL`,
|
||||
initialValue: params.defaultBaseUrl,
|
||||
@ -152,6 +174,13 @@ export async function promptAndConfigureOpenAICompatibleSelfHostedProvider(param
|
||||
};
|
||||
}
|
||||
|
||||
export async function promptAndConfigureOpenAICompatibleSelfHostedProviderAuth(
|
||||
params: OpenAICompatibleSelfHostedProviderSetupParams,
|
||||
): Promise<ProviderAuthResult> {
|
||||
const result = await promptAndConfigureOpenAICompatibleSelfHostedProvider(params);
|
||||
return buildSelfHostedProviderAuthResult(result);
|
||||
}
|
||||
|
||||
function buildMissingNonInteractiveModelIdMessage(params: {
|
||||
authChoice: string;
|
||||
providerLabel: string;
|
||||
|
||||
@ -37,139 +37,94 @@ function makeMattermostPlugin(): ChannelPlugin {
|
||||
};
|
||||
}
|
||||
|
||||
function makeSlackPlugin(params?: { botToken?: string; appToken?: string }): ChannelPlugin {
|
||||
return {
|
||||
id: "slack",
|
||||
meta: {
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
selectionLabel: "Slack",
|
||||
docsPath: "/channels/slack",
|
||||
blurb: "test",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
botToken: params?.botToken ?? "bot-token",
|
||||
appToken: params?.appToken ?? "app-token",
|
||||
}),
|
||||
resolveAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
botToken: params?.botToken ?? "bot-token",
|
||||
appToken: params?.appToken ?? "app-token",
|
||||
}),
|
||||
isConfigured: () => true,
|
||||
isEnabled: () => true,
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send"],
|
||||
},
|
||||
};
|
||||
}
|
||||
type TestTable = Awaited<ReturnType<typeof buildChannelsTable>>;
|
||||
|
||||
function makeUnavailableSlackPlugin(): ChannelPlugin {
|
||||
return {
|
||||
id: "slack",
|
||||
meta: {
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
selectionLabel: "Slack",
|
||||
docsPath: "/channels/slack",
|
||||
blurb: "test",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "config",
|
||||
botTokenStatus: "configured_unavailable",
|
||||
appTokenStatus: "configured_unavailable",
|
||||
}),
|
||||
resolveAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "config",
|
||||
botTokenStatus: "configured_unavailable",
|
||||
appTokenStatus: "configured_unavailable",
|
||||
}),
|
||||
isConfigured: () => true,
|
||||
isEnabled: () => true,
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeSourceAwareUnavailablePlugin(): ChannelPlugin {
|
||||
function makeSlackDirectPlugin(config: ChannelPlugin["config"]): ChannelPlugin {
|
||||
return makeDirectPlugin({
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
docsPath: "/channels/slack",
|
||||
config: {
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: (cfg) =>
|
||||
(cfg as { marker?: string }).marker === "source"
|
||||
? {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "config",
|
||||
botTokenStatus: "configured_unavailable",
|
||||
appTokenStatus: "configured_unavailable",
|
||||
}
|
||||
: {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: false,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
botTokenSource: "none",
|
||||
appTokenSource: "none",
|
||||
},
|
||||
resolveAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
}),
|
||||
isConfigured: (account) => Boolean((account as { configured?: boolean }).configured),
|
||||
isEnabled: () => true,
|
||||
},
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
function createSlackTokenAccount(params?: { botToken?: string; appToken?: string }) {
|
||||
return {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
botToken: params?.botToken ?? "bot-token",
|
||||
appToken: params?.appToken ?? "app-token",
|
||||
};
|
||||
}
|
||||
|
||||
function createUnavailableSlackTokenAccount() {
|
||||
return {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "config",
|
||||
botTokenStatus: "configured_unavailable",
|
||||
appTokenStatus: "configured_unavailable",
|
||||
};
|
||||
}
|
||||
|
||||
function makeSlackPlugin(params?: { botToken?: string; appToken?: string }): ChannelPlugin {
|
||||
return makeSlackDirectPlugin({
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: () => createSlackTokenAccount(params),
|
||||
resolveAccount: () => createSlackTokenAccount(params),
|
||||
isConfigured: () => true,
|
||||
isEnabled: () => true,
|
||||
});
|
||||
}
|
||||
|
||||
function makeUnavailableSlackPlugin(): ChannelPlugin {
|
||||
return makeSlackDirectPlugin({
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: () => createUnavailableSlackTokenAccount(),
|
||||
resolveAccount: () => createUnavailableSlackTokenAccount(),
|
||||
isConfigured: () => true,
|
||||
isEnabled: () => true,
|
||||
});
|
||||
}
|
||||
|
||||
function makeSourceAwareUnavailablePlugin(): ChannelPlugin {
|
||||
return makeSlackDirectPlugin({
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: (cfg) =>
|
||||
(cfg as { marker?: string }).marker === "source"
|
||||
? createUnavailableSlackTokenAccount()
|
||||
: {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: false,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
botTokenSource: "none",
|
||||
appTokenSource: "none",
|
||||
},
|
||||
resolveAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
}),
|
||||
isConfigured: (account) => Boolean((account as { configured?: boolean }).configured),
|
||||
isEnabled: () => true,
|
||||
});
|
||||
}
|
||||
|
||||
function makeSourceUnavailableResolvedAvailablePlugin(): ChannelPlugin {
|
||||
return {
|
||||
return makeDirectPlugin({
|
||||
id: "discord",
|
||||
meta: {
|
||||
id: "discord",
|
||||
label: "Discord",
|
||||
selectionLabel: "Discord",
|
||||
docsPath: "/channels/discord",
|
||||
blurb: "test",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
label: "Discord",
|
||||
docsPath: "/channels/discord",
|
||||
config: {
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
@ -199,10 +154,7 @@ function makeSourceUnavailableResolvedAvailablePlugin(): ChannelPlugin {
|
||||
isConfigured: (account) => Boolean((account as { configured?: boolean }).configured),
|
||||
isEnabled: () => true,
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send"],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function makeHttpSlackUnavailablePlugin(): ChannelPlugin {
|
||||
@ -263,64 +215,76 @@ function makeTokenPlugin(): ChannelPlugin {
|
||||
});
|
||||
}
|
||||
|
||||
async function buildTestTable(
|
||||
plugins: ChannelPlugin[],
|
||||
params?: { cfg?: Record<string, unknown>; sourceConfig?: Record<string, unknown> },
|
||||
) {
|
||||
vi.mocked(listChannelPlugins).mockReturnValue(plugins);
|
||||
return await buildChannelsTable((params?.cfg ?? { channels: {} }) as never, {
|
||||
showSecrets: false,
|
||||
sourceConfig: params?.sourceConfig as never,
|
||||
});
|
||||
}
|
||||
|
||||
function expectTableRow(
|
||||
table: TestTable,
|
||||
params: { id: string; state: string; detailContains?: string; detailEquals?: string },
|
||||
) {
|
||||
const row = table.rows.find((entry) => entry.id === params.id);
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.state).toBe(params.state);
|
||||
if (params.detailContains) {
|
||||
expect(row?.detail).toContain(params.detailContains);
|
||||
}
|
||||
if (params.detailEquals) {
|
||||
expect(row?.detail).toBe(params.detailEquals);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function expectTableDetailRows(
|
||||
table: TestTable,
|
||||
title: string,
|
||||
rows: Array<Record<string, string>>,
|
||||
) {
|
||||
const detail = table.details.find((entry) => entry.title === title);
|
||||
expect(detail).toBeDefined();
|
||||
expect(detail?.rows).toEqual(rows);
|
||||
}
|
||||
|
||||
describe("buildChannelsTable - mattermost token summary", () => {
|
||||
it("does not require appToken for mattermost accounts", async () => {
|
||||
vi.mocked(listChannelPlugins).mockReturnValue([makeMattermostPlugin()]);
|
||||
|
||||
const table = await buildChannelsTable({ channels: {} } as never, {
|
||||
showSecrets: false,
|
||||
});
|
||||
|
||||
const mattermostRow = table.rows.find((row) => row.id === "mattermost");
|
||||
expect(mattermostRow).toBeDefined();
|
||||
expect(mattermostRow?.state).toBe("ok");
|
||||
const table = await buildTestTable([makeMattermostPlugin()]);
|
||||
const mattermostRow = expectTableRow(table, { id: "mattermost", state: "ok" });
|
||||
expect(mattermostRow?.detail).not.toContain("need bot+app");
|
||||
});
|
||||
|
||||
it("keeps bot+app requirement when both fields exist", async () => {
|
||||
vi.mocked(listChannelPlugins).mockReturnValue([
|
||||
makeSlackPlugin({ botToken: "bot-token", appToken: "" }),
|
||||
]);
|
||||
|
||||
const table = await buildChannelsTable({ channels: {} } as never, {
|
||||
showSecrets: false,
|
||||
});
|
||||
|
||||
const slackRow = table.rows.find((row) => row.id === "slack");
|
||||
expect(slackRow).toBeDefined();
|
||||
expect(slackRow?.state).toBe("warn");
|
||||
expect(slackRow?.detail).toContain("need bot+app");
|
||||
const table = await buildTestTable([makeSlackPlugin({ botToken: "bot-token", appToken: "" })]);
|
||||
expectTableRow(table, { id: "slack", state: "warn", detailContains: "need bot+app" });
|
||||
});
|
||||
|
||||
it("reports configured-but-unavailable Slack credentials as warn", async () => {
|
||||
vi.mocked(listChannelPlugins).mockReturnValue([makeUnavailableSlackPlugin()]);
|
||||
|
||||
const table = await buildChannelsTable({ channels: {} } as never, {
|
||||
showSecrets: false,
|
||||
const table = await buildTestTable([makeUnavailableSlackPlugin()]);
|
||||
expectTableRow(table, {
|
||||
id: "slack",
|
||||
state: "warn",
|
||||
detailContains: "unavailable in this command path",
|
||||
});
|
||||
|
||||
const slackRow = table.rows.find((row) => row.id === "slack");
|
||||
expect(slackRow).toBeDefined();
|
||||
expect(slackRow?.state).toBe("warn");
|
||||
expect(slackRow?.detail).toContain("unavailable in this command path");
|
||||
});
|
||||
|
||||
it("preserves unavailable credential state from the source config snapshot", async () => {
|
||||
vi.mocked(listChannelPlugins).mockReturnValue([makeSourceAwareUnavailablePlugin()]);
|
||||
|
||||
const table = await buildChannelsTable({ marker: "resolved", channels: {} } as never, {
|
||||
showSecrets: false,
|
||||
sourceConfig: { marker: "source", channels: {} } as never,
|
||||
const table = await buildTestTable([makeSourceAwareUnavailablePlugin()], {
|
||||
cfg: { marker: "resolved", channels: {} },
|
||||
sourceConfig: { marker: "source", channels: {} },
|
||||
});
|
||||
|
||||
const slackRow = table.rows.find((row) => row.id === "slack");
|
||||
expect(slackRow).toBeDefined();
|
||||
expect(slackRow?.state).toBe("warn");
|
||||
expect(slackRow?.detail).toContain("unavailable in this command path");
|
||||
|
||||
const slackDetails = table.details.find((detail) => detail.title === "Slack accounts");
|
||||
expect(slackDetails).toBeDefined();
|
||||
expect(slackDetails?.rows).toEqual([
|
||||
expectTableRow(table, {
|
||||
id: "slack",
|
||||
state: "warn",
|
||||
detailContains: "unavailable in this command path",
|
||||
});
|
||||
expectTableDetailRows(table, "Slack accounts", [
|
||||
{
|
||||
Account: "primary (Primary)",
|
||||
Notes: "bot:config · app:config · secret unavailable in this command path",
|
||||
@ -330,21 +294,13 @@ describe("buildChannelsTable - mattermost token summary", () => {
|
||||
});
|
||||
|
||||
it("treats status-only available credentials as resolved", async () => {
|
||||
vi.mocked(listChannelPlugins).mockReturnValue([makeSourceUnavailableResolvedAvailablePlugin()]);
|
||||
|
||||
const table = await buildChannelsTable({ marker: "resolved", channels: {} } as never, {
|
||||
showSecrets: false,
|
||||
sourceConfig: { marker: "source", channels: {} } as never,
|
||||
const table = await buildTestTable([makeSourceUnavailableResolvedAvailablePlugin()], {
|
||||
cfg: { marker: "resolved", channels: {} },
|
||||
sourceConfig: { marker: "source", channels: {} },
|
||||
});
|
||||
|
||||
const discordRow = table.rows.find((row) => row.id === "discord");
|
||||
expect(discordRow).toBeDefined();
|
||||
expect(discordRow?.state).toBe("ok");
|
||||
expect(discordRow?.detail).toBe("configured");
|
||||
|
||||
const discordDetails = table.details.find((detail) => detail.title === "Discord accounts");
|
||||
expect(discordDetails).toBeDefined();
|
||||
expect(discordDetails?.rows).toEqual([
|
||||
expectTableRow(table, { id: "discord", state: "ok", detailEquals: "configured" });
|
||||
expectTableDetailRows(table, "Discord accounts", [
|
||||
{
|
||||
Account: "primary (Primary)",
|
||||
Notes: "token:config",
|
||||
@ -354,20 +310,13 @@ describe("buildChannelsTable - mattermost token summary", () => {
|
||||
});
|
||||
|
||||
it("treats Slack HTTP signing-secret availability as required config", async () => {
|
||||
vi.mocked(listChannelPlugins).mockReturnValue([makeHttpSlackUnavailablePlugin()]);
|
||||
|
||||
const table = await buildChannelsTable({ channels: {} } as never, {
|
||||
showSecrets: false,
|
||||
const table = await buildTestTable([makeHttpSlackUnavailablePlugin()]);
|
||||
expectTableRow(table, {
|
||||
id: "slack",
|
||||
state: "warn",
|
||||
detailContains: "configured http credentials unavailable",
|
||||
});
|
||||
|
||||
const slackRow = table.rows.find((row) => row.id === "slack");
|
||||
expect(slackRow).toBeDefined();
|
||||
expect(slackRow?.state).toBe("warn");
|
||||
expect(slackRow?.detail).toContain("configured http credentials unavailable");
|
||||
|
||||
const slackDetails = table.details.find((detail) => detail.title === "Slack accounts");
|
||||
expect(slackDetails).toBeDefined();
|
||||
expect(slackDetails?.rows).toEqual([
|
||||
expectTableDetailRows(table, "Slack accounts", [
|
||||
{
|
||||
Account: "primary (Primary)",
|
||||
Notes: "bot:config · signing:config · secret unavailable in this command path",
|
||||
@ -377,15 +326,7 @@ describe("buildChannelsTable - mattermost token summary", () => {
|
||||
});
|
||||
|
||||
it("still reports single-token channels as ok", async () => {
|
||||
vi.mocked(listChannelPlugins).mockReturnValue([makeTokenPlugin()]);
|
||||
|
||||
const table = await buildChannelsTable({ channels: {} } as never, {
|
||||
showSecrets: false,
|
||||
});
|
||||
|
||||
const tokenRow = table.rows.find((row) => row.id === "token-only");
|
||||
expect(tokenRow).toBeDefined();
|
||||
expect(tokenRow?.state).toBe("ok");
|
||||
expect(tokenRow?.detail).toContain("token");
|
||||
const table = await buildTestTable([makeTokenPlugin()]);
|
||||
expectTableRow(table, { id: "token-only", state: "ok", detailContains: "token" });
|
||||
});
|
||||
});
|
||||
|
||||
@ -40,6 +40,14 @@ function createCustomRootCfg(customRoot: string, defaultAgentId = "ops"): OpenCl
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveTargetsForCustomRoot(home: string, agentIds: string[]) {
|
||||
const customRoot = path.join(home, "custom-state");
|
||||
const storePaths = await createAgentSessionStores(customRoot, agentIds);
|
||||
const cfg = createCustomRootCfg(customRoot);
|
||||
const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
|
||||
return { storePaths, targets };
|
||||
}
|
||||
|
||||
function expectTargetsToContainStores(
|
||||
targets: Array<{ agentId: string; storePath: string }>,
|
||||
stores: Record<string, string>,
|
||||
@ -152,11 +160,7 @@ describe("resolveAllAgentSessionStoreTargets", () => {
|
||||
|
||||
it("discovers retired agent stores under a configured custom session root", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const customRoot = path.join(home, "custom-state");
|
||||
const storePaths = await createAgentSessionStores(customRoot, ["ops", "retired"]);
|
||||
const cfg = createCustomRootCfg(customRoot);
|
||||
|
||||
const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
|
||||
const { storePaths, targets } = await resolveTargetsForCustomRoot(home, ["ops", "retired"]);
|
||||
|
||||
expectTargetsToContainStores(targets, storePaths);
|
||||
expect(targets.filter((target) => target.storePath === storePaths.ops)).toHaveLength(1);
|
||||
@ -165,11 +169,10 @@ describe("resolveAllAgentSessionStoreTargets", () => {
|
||||
|
||||
it("keeps the actual on-disk store path for discovered retired agents", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const customRoot = path.join(home, "custom-state");
|
||||
const storePaths = await createAgentSessionStores(customRoot, ["ops", "Retired Agent"]);
|
||||
const cfg = createCustomRootCfg(customRoot);
|
||||
|
||||
const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
|
||||
const { storePaths, targets } = await resolveTargetsForCustomRoot(home, [
|
||||
"ops",
|
||||
"Retired Agent",
|
||||
]);
|
||||
|
||||
expect(targets).toEqual(
|
||||
expect.arrayContaining([
|
||||
|
||||
@ -19,34 +19,7 @@ export type DiscordAllowListMatch = AllowlistMatch<"wildcard" | "id" | "name" |
|
||||
|
||||
const DISCORD_OWNER_ALLOWLIST_PREFIXES = ["discord:", "user:", "pk:"];
|
||||
|
||||
export type DiscordGuildEntryResolved = {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
requireMention?: boolean;
|
||||
ignoreOtherMentions?: boolean;
|
||||
reactionNotifications?: "off" | "own" | "all" | "allowlist";
|
||||
users?: string[];
|
||||
roles?: string[];
|
||||
channels?: Record<
|
||||
string,
|
||||
{
|
||||
allow?: boolean;
|
||||
requireMention?: boolean;
|
||||
ignoreOtherMentions?: boolean;
|
||||
skills?: string[];
|
||||
enabled?: boolean;
|
||||
users?: string[];
|
||||
roles?: string[];
|
||||
systemPrompt?: string;
|
||||
includeThreadStarter?: boolean;
|
||||
autoThread?: boolean;
|
||||
autoArchiveDuration?: "60" | "1440" | "4320" | "10080" | 60 | 1440 | 4320 | 10080;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export type DiscordChannelConfigResolved = {
|
||||
allowed: boolean;
|
||||
type DiscordChannelOverrideConfig = {
|
||||
requireMention?: boolean;
|
||||
ignoreOtherMentions?: boolean;
|
||||
skills?: string[];
|
||||
@ -57,6 +30,21 @@ export type DiscordChannelConfigResolved = {
|
||||
includeThreadStarter?: boolean;
|
||||
autoThread?: boolean;
|
||||
autoArchiveDuration?: "60" | "1440" | "4320" | "10080" | 60 | 1440 | 4320 | 10080;
|
||||
};
|
||||
|
||||
export type DiscordGuildEntryResolved = {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
requireMention?: boolean;
|
||||
ignoreOtherMentions?: boolean;
|
||||
reactionNotifications?: "off" | "own" | "all" | "allowlist";
|
||||
users?: string[];
|
||||
roles?: string[];
|
||||
channels?: Record<string, { allow?: boolean } & DiscordChannelOverrideConfig>;
|
||||
};
|
||||
|
||||
export type DiscordChannelConfigResolved = DiscordChannelOverrideConfig & {
|
||||
allowed: boolean;
|
||||
matchKey?: string;
|
||||
matchSource?: ChannelMatchSource;
|
||||
};
|
||||
|
||||
@ -116,6 +116,62 @@ function createHandler(config: DiscordExecApprovalConfig, accountId = "default")
|
||||
});
|
||||
}
|
||||
|
||||
function mockSuccessfulDmDelivery(params?: {
|
||||
noteChannelId?: string;
|
||||
expectedNoteText?: string;
|
||||
throwOnUnexpectedRoute?: boolean;
|
||||
}) {
|
||||
mockRestPost.mockImplementation(
|
||||
async (route: string, requestParams?: { body?: { content?: string } }) => {
|
||||
if (params?.noteChannelId && route === Routes.channelMessages(params.noteChannelId)) {
|
||||
if (params.expectedNoteText) {
|
||||
expect(requestParams?.body?.content).toContain(params.expectedNoteText);
|
||||
}
|
||||
return { id: "note-1", channel_id: params.noteChannelId };
|
||||
}
|
||||
if (route === Routes.userChannels()) {
|
||||
return { id: "dm-1" };
|
||||
}
|
||||
if (route === Routes.channelMessages("dm-1")) {
|
||||
return { id: "msg-1", channel_id: "dm-1" };
|
||||
}
|
||||
if (params?.throwOnUnexpectedRoute) {
|
||||
throw new Error(`unexpected route: ${route}`);
|
||||
}
|
||||
return { id: "msg-unknown" };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function expectGatewayAuthStart(params: {
|
||||
handler: DiscordExecApprovalHandler;
|
||||
expectedUrl: string;
|
||||
expectedSource: "cli" | "env";
|
||||
expectedToken?: string;
|
||||
expectedPassword?: string;
|
||||
}) {
|
||||
await params.handler.start();
|
||||
|
||||
expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: process.env,
|
||||
urlOverride: params.expectedUrl,
|
||||
urlOverrideSource: params.expectedSource,
|
||||
}),
|
||||
);
|
||||
|
||||
const expectedClientParams: Record<string, unknown> = {
|
||||
url: params.expectedUrl,
|
||||
};
|
||||
if (params.expectedToken !== undefined) {
|
||||
expectedClientParams.token = params.expectedToken;
|
||||
}
|
||||
if (params.expectedPassword !== undefined) {
|
||||
expectedClientParams.password = params.expectedPassword;
|
||||
}
|
||||
expect(mockGatewayClientCtor).toHaveBeenCalledWith(expect.objectContaining(expectedClientParams));
|
||||
}
|
||||
|
||||
type ExecApprovalHandlerInternals = {
|
||||
pending: Map<
|
||||
string,
|
||||
@ -772,15 +828,7 @@ describe("DiscordExecApprovalHandler delivery routing", () => {
|
||||
});
|
||||
const internals = getHandlerInternals(handler);
|
||||
|
||||
mockRestPost.mockImplementation(async (route: string) => {
|
||||
if (route === Routes.userChannels()) {
|
||||
return { id: "dm-1" };
|
||||
}
|
||||
if (route === Routes.channelMessages("dm-1")) {
|
||||
return { id: "msg-1", channel_id: "dm-1" };
|
||||
}
|
||||
return { id: "msg-unknown" };
|
||||
});
|
||||
mockSuccessfulDmDelivery();
|
||||
|
||||
const request = createRequest({ sessionKey: "agent:main:discord:dm:123" });
|
||||
await internals.handleApprovalRequested(request);
|
||||
@ -809,21 +857,11 @@ describe("DiscordExecApprovalHandler delivery routing", () => {
|
||||
});
|
||||
const internals = getHandlerInternals(handler);
|
||||
|
||||
mockRestPost.mockImplementation(
|
||||
async (route: string, params?: { body?: { content?: string } }) => {
|
||||
if (route === Routes.channelMessages("999888777")) {
|
||||
expect(params?.body?.content).toContain("I sent the allowed approvers DMs");
|
||||
return { id: "note-1", channel_id: "999888777" };
|
||||
}
|
||||
if (route === Routes.userChannels()) {
|
||||
return { id: "dm-1" };
|
||||
}
|
||||
if (route === Routes.channelMessages("dm-1")) {
|
||||
return { id: "msg-1", channel_id: "dm-1" };
|
||||
}
|
||||
throw new Error(`unexpected route: ${route}`);
|
||||
},
|
||||
);
|
||||
mockSuccessfulDmDelivery({
|
||||
noteChannelId: "999888777",
|
||||
expectedNoteText: "I sent the allowed approvers DMs",
|
||||
throwOnUnexpectedRoute: true,
|
||||
});
|
||||
|
||||
await internals.handleApprovalRequested(createRequest());
|
||||
|
||||
@ -853,15 +891,7 @@ describe("DiscordExecApprovalHandler delivery routing", () => {
|
||||
});
|
||||
const internals = getHandlerInternals(handler);
|
||||
|
||||
mockRestPost.mockImplementation(async (route: string) => {
|
||||
if (route === Routes.userChannels()) {
|
||||
return { id: "dm-1" };
|
||||
}
|
||||
if (route === Routes.channelMessages("dm-1")) {
|
||||
return { id: "msg-1", channel_id: "dm-1" };
|
||||
}
|
||||
throw new Error(`unexpected route: ${route}`);
|
||||
});
|
||||
mockSuccessfulDmDelivery({ throwOnUnexpectedRoute: true });
|
||||
|
||||
await internals.handleApprovalRequested(
|
||||
createRequest({ sessionKey: "agent:main:discord:dm:123" }),
|
||||
@ -890,22 +920,13 @@ describe("DiscordExecApprovalHandler gateway auth resolution", () => {
|
||||
cfg: { session: { store: STORE_PATH } },
|
||||
});
|
||||
|
||||
await handler.start();
|
||||
|
||||
expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: process.env,
|
||||
urlOverride: "wss://override.example/ws",
|
||||
urlOverrideSource: "cli",
|
||||
}),
|
||||
);
|
||||
expect(mockGatewayClientCtor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "wss://override.example/ws",
|
||||
token: "resolved-token",
|
||||
password: "resolved-password", // pragma: allowlist secret
|
||||
}),
|
||||
);
|
||||
await expectGatewayAuthStart({
|
||||
handler,
|
||||
expectedUrl: "wss://override.example/ws",
|
||||
expectedSource: "cli",
|
||||
expectedToken: "resolved-token",
|
||||
expectedPassword: "resolved-password", // pragma: allowlist secret
|
||||
});
|
||||
|
||||
await handler.stop();
|
||||
});
|
||||
@ -921,20 +942,11 @@ describe("DiscordExecApprovalHandler gateway auth resolution", () => {
|
||||
cfg: { session: { store: STORE_PATH } },
|
||||
});
|
||||
|
||||
await handler.start();
|
||||
|
||||
expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: process.env,
|
||||
urlOverride: "wss://gateway-from-env.example/ws",
|
||||
urlOverrideSource: "env",
|
||||
}),
|
||||
);
|
||||
expect(mockGatewayClientCtor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "wss://gateway-from-env.example/ws",
|
||||
}),
|
||||
);
|
||||
await expectGatewayAuthStart({
|
||||
handler,
|
||||
expectedUrl: "wss://gateway-from-env.example/ws",
|
||||
expectedSource: "env",
|
||||
});
|
||||
|
||||
await handler.stop();
|
||||
} finally {
|
||||
|
||||
@ -252,17 +252,30 @@ function formatOptionalCommandPreview(
|
||||
return formatCommandPreview(commandText, maxChars);
|
||||
}
|
||||
|
||||
function resolveExecApprovalPreviews(
|
||||
request: ExecApprovalRequest["request"],
|
||||
maxChars: number,
|
||||
secondaryMaxChars: number,
|
||||
): { commandPreview: string; commandSecondaryPreview: string | null } {
|
||||
const { commandText, commandPreview: secondaryPreview } =
|
||||
resolveExecApprovalCommandDisplay(request);
|
||||
return {
|
||||
commandPreview: formatCommandPreview(commandText, maxChars),
|
||||
commandSecondaryPreview: formatOptionalCommandPreview(secondaryPreview, secondaryMaxChars),
|
||||
};
|
||||
}
|
||||
|
||||
function createExecApprovalRequestContainer(params: {
|
||||
request: ExecApprovalRequest;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
actionRow?: Row<Button>;
|
||||
}): ExecApprovalContainer {
|
||||
const { commandText, commandPreview: secondaryPreview } = resolveExecApprovalCommandDisplay(
|
||||
const { commandPreview, commandSecondaryPreview } = resolveExecApprovalPreviews(
|
||||
params.request.request,
|
||||
1000,
|
||||
500,
|
||||
);
|
||||
const commandPreview = formatCommandPreview(commandText, 1000);
|
||||
const commandSecondaryPreview = formatOptionalCommandPreview(secondaryPreview, 500);
|
||||
const expiresAtSeconds = Math.max(0, Math.floor(params.request.expiresAtMs / 1000));
|
||||
|
||||
return new ExecApprovalContainer({
|
||||
@ -286,11 +299,11 @@ function createResolvedContainer(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
}): ExecApprovalContainer {
|
||||
const { commandText, commandPreview: secondaryPreview } = resolveExecApprovalCommandDisplay(
|
||||
const { commandPreview, commandSecondaryPreview } = resolveExecApprovalPreviews(
|
||||
params.request.request,
|
||||
500,
|
||||
300,
|
||||
);
|
||||
const commandPreview = formatCommandPreview(commandText, 500);
|
||||
const commandSecondaryPreview = formatOptionalCommandPreview(secondaryPreview, 300);
|
||||
|
||||
const decisionLabel =
|
||||
params.decision === "allow-once"
|
||||
@ -323,11 +336,11 @@ function createExpiredContainer(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
}): ExecApprovalContainer {
|
||||
const { commandText, commandPreview: secondaryPreview } = resolveExecApprovalCommandDisplay(
|
||||
const { commandPreview, commandSecondaryPreview } = resolveExecApprovalPreviews(
|
||||
params.request.request,
|
||||
500,
|
||||
300,
|
||||
);
|
||||
const commandPreview = formatCommandPreview(commandText, 500);
|
||||
const commandSecondaryPreview = formatOptionalCommandPreview(secondaryPreview, 300);
|
||||
|
||||
return new ExecApprovalContainer({
|
||||
cfg: params.cfg,
|
||||
|
||||
@ -12,6 +12,14 @@ function fakeEvent(channelId: string) {
|
||||
return { channel_id: channelId } as never;
|
||||
}
|
||||
|
||||
function createDeferred() {
|
||||
let resolve: (() => void) | undefined;
|
||||
const promise = new Promise<void>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("DiscordMessageListener", () => {
|
||||
it("returns immediately without awaiting handler completion", async () => {
|
||||
let resolveHandler: (() => void) | undefined;
|
||||
@ -38,23 +46,17 @@ describe("DiscordMessageListener", () => {
|
||||
|
||||
it("runs handlers for the same channel concurrently (no per-channel serialization)", async () => {
|
||||
const order: string[] = [];
|
||||
let resolveA: (() => void) | undefined;
|
||||
let resolveB: (() => void) | undefined;
|
||||
const doneA = new Promise<void>((r) => {
|
||||
resolveA = r;
|
||||
});
|
||||
const doneB = new Promise<void>((r) => {
|
||||
resolveB = r;
|
||||
});
|
||||
const deferredA = createDeferred();
|
||||
const deferredB = createDeferred();
|
||||
let callCount = 0;
|
||||
const handler = vi.fn(async () => {
|
||||
callCount += 1;
|
||||
const id = callCount;
|
||||
order.push(`start:${id}`);
|
||||
if (id === 1) {
|
||||
await doneA;
|
||||
await deferredA.promise;
|
||||
} else {
|
||||
await doneB;
|
||||
await deferredB.promise;
|
||||
}
|
||||
order.push(`end:${id}`);
|
||||
});
|
||||
@ -71,35 +73,29 @@ describe("DiscordMessageListener", () => {
|
||||
expect(order).toContain("start:1");
|
||||
expect(order).toContain("start:2");
|
||||
|
||||
resolveB?.();
|
||||
deferredB.resolve?.();
|
||||
await vi.waitFor(() => {
|
||||
expect(order).toContain("end:2");
|
||||
});
|
||||
// First handler is still running — no serialization.
|
||||
expect(order).not.toContain("end:1");
|
||||
|
||||
resolveA?.();
|
||||
deferredA.resolve?.();
|
||||
await vi.waitFor(() => {
|
||||
expect(order).toContain("end:1");
|
||||
});
|
||||
});
|
||||
|
||||
it("runs handlers for different channels in parallel", async () => {
|
||||
let resolveA: (() => void) | undefined;
|
||||
let resolveB: (() => void) | undefined;
|
||||
const doneA = new Promise<void>((r) => {
|
||||
resolveA = r;
|
||||
});
|
||||
const doneB = new Promise<void>((r) => {
|
||||
resolveB = r;
|
||||
});
|
||||
const deferredA = createDeferred();
|
||||
const deferredB = createDeferred();
|
||||
const order: string[] = [];
|
||||
const handler = vi.fn(async (data: { channel_id: string }) => {
|
||||
order.push(`start:${data.channel_id}`);
|
||||
if (data.channel_id === "ch-a") {
|
||||
await doneA;
|
||||
await deferredA.promise;
|
||||
} else {
|
||||
await doneB;
|
||||
await deferredB.promise;
|
||||
}
|
||||
order.push(`end:${data.channel_id}`);
|
||||
});
|
||||
@ -114,13 +110,13 @@ describe("DiscordMessageListener", () => {
|
||||
expect(order).toContain("start:ch-a");
|
||||
expect(order).toContain("start:ch-b");
|
||||
|
||||
resolveB?.();
|
||||
deferredB.resolve?.();
|
||||
await vi.waitFor(() => {
|
||||
expect(order).toContain("end:ch-b");
|
||||
});
|
||||
expect(order).not.toContain("end:ch-a");
|
||||
|
||||
resolveA?.();
|
||||
deferredA.resolve?.();
|
||||
await vi.waitFor(() => {
|
||||
expect(order).toContain("end:ch-a");
|
||||
});
|
||||
|
||||
@ -1,23 +1,15 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createDiscordMessageHandler,
|
||||
preflightDiscordMessageMock,
|
||||
processDiscordMessageMock,
|
||||
} from "./message-handler.module-test-helpers.js";
|
||||
import {
|
||||
DEFAULT_DISCORD_BOT_USER_ID,
|
||||
createDiscordHandlerParams,
|
||||
createDiscordPreflightContext,
|
||||
} from "./message-handler.test-helpers.js";
|
||||
|
||||
const preflightDiscordMessageMock = vi.hoisted(() => vi.fn());
|
||||
const processDiscordMessageMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./message-handler.preflight.js", () => ({
|
||||
preflightDiscordMessage: preflightDiscordMessageMock,
|
||||
}));
|
||||
|
||||
vi.mock("./message-handler.process.js", () => ({
|
||||
processDiscordMessage: processDiscordMessageMock,
|
||||
}));
|
||||
|
||||
const { createDiscordMessageHandler } = await import("./message-handler.js");
|
||||
|
||||
function createMessageData(authorId: string, channelId = "ch-1") {
|
||||
return {
|
||||
author: { id: authorId, bot: authorId === DEFAULT_DISCORD_BOT_USER_ID },
|
||||
|
||||
14
src/discord/monitor/message-handler.module-test-helpers.ts
Normal file
14
src/discord/monitor/message-handler.module-test-helpers.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export const preflightDiscordMessageMock = vi.fn();
|
||||
export const processDiscordMessageMock = vi.fn();
|
||||
|
||||
vi.mock("./message-handler.preflight.js", () => ({
|
||||
preflightDiscordMessage: preflightDiscordMessageMock,
|
||||
}));
|
||||
|
||||
vi.mock("./message-handler.process.js", () => ({
|
||||
processDiscordMessage: processDiscordMessageMock,
|
||||
}));
|
||||
|
||||
export const { createDiscordMessageHandler } = await import("./message-handler.js");
|
||||
@ -1,4 +1,3 @@
|
||||
import { ChannelType } from "@buape/carbon";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn());
|
||||
@ -13,7 +12,13 @@ vi.mock("../../acp/persistent-bindings.js", () => ({
|
||||
|
||||
import { __testing as sessionBindingTesting } from "../../infra/outbound/session-binding-service.js";
|
||||
import { preflightDiscordMessage } from "./message-handler.preflight.js";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||
import {
|
||||
createDiscordMessage,
|
||||
createDiscordPreflightArgs,
|
||||
createGuildEvent,
|
||||
createGuildTextClient,
|
||||
DEFAULT_PREFLIGHT_CFG,
|
||||
} from "./message-handler.preflight.test-helpers.js";
|
||||
|
||||
const GUILD_ID = "guild-1";
|
||||
const CHANNEL_ID = "channel-1";
|
||||
@ -48,70 +53,36 @@ function createConfiguredDiscordBinding() {
|
||||
}
|
||||
|
||||
function createBasePreflightParams(overrides?: Record<string, unknown>) {
|
||||
const message = {
|
||||
const message = createDiscordMessage({
|
||||
id: "m-1",
|
||||
content: "<@bot-1> hello",
|
||||
timestamp: new Date().toISOString(),
|
||||
channelId: CHANNEL_ID,
|
||||
attachments: [],
|
||||
content: "<@bot-1> hello",
|
||||
mentionedUsers: [{ id: "bot-1" }],
|
||||
mentionedRoles: [],
|
||||
mentionedEveryone: false,
|
||||
author: {
|
||||
id: "user-1",
|
||||
bot: false,
|
||||
username: "alice",
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Message;
|
||||
|
||||
const client = {
|
||||
fetchChannel: async (channelId: string) => {
|
||||
if (channelId === CHANNEL_ID) {
|
||||
return {
|
||||
id: CHANNEL_ID,
|
||||
type: ChannelType.GuildText,
|
||||
name: "general",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Client;
|
||||
});
|
||||
|
||||
return {
|
||||
cfg: {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
} as import("../../config/config.js").OpenClawConfig,
|
||||
...createDiscordPreflightArgs({
|
||||
cfg: DEFAULT_PREFLIGHT_CFG,
|
||||
discordConfig: {
|
||||
allowBots: true,
|
||||
} as NonNullable<import("../../config/config.js").OpenClawConfig["channels"]>["discord"],
|
||||
data: createGuildEvent({
|
||||
channelId: CHANNEL_ID,
|
||||
guildId: GUILD_ID,
|
||||
author: message.author,
|
||||
message,
|
||||
}),
|
||||
client: createGuildTextClient(CHANNEL_ID),
|
||||
botUserId: "bot-1",
|
||||
}),
|
||||
discordConfig: {
|
||||
allowBots: true,
|
||||
} as NonNullable<import("../../config/config.js").OpenClawConfig["channels"]>["discord"],
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {} as import("../../runtime.js").RuntimeEnv,
|
||||
botUserId: "bot-1",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 1_000_000,
|
||||
textLimit: 2_000,
|
||||
replyToMode: "all",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: true,
|
||||
ackReactionScope: "direct",
|
||||
groupPolicy: "open",
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
data: {
|
||||
channel_id: CHANNEL_ID,
|
||||
guild_id: GUILD_ID,
|
||||
guild: {
|
||||
id: GUILD_ID,
|
||||
name: "Guild One",
|
||||
},
|
||||
author: message.author,
|
||||
message,
|
||||
} as unknown as import("./listeners.js").DiscordMessageEvent,
|
||||
client,
|
||||
...overrides,
|
||||
} satisfies Parameters<typeof preflightDiscordMessage>[0];
|
||||
}
|
||||
|
||||
103
src/discord/monitor/message-handler.preflight.test-helpers.ts
Normal file
103
src/discord/monitor/message-handler.preflight.test-helpers.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { ChannelType } from "@buape/carbon";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { preflightDiscordMessage } from "./message-handler.preflight.js";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
export type DiscordConfig = NonNullable<OpenClawConfig["channels"]>["discord"];
|
||||
export type DiscordMessageEvent = import("./listeners.js").DiscordMessageEvent;
|
||||
export type DiscordClient = import("@buape/carbon").Client;
|
||||
|
||||
export const DEFAULT_PREFLIGHT_CFG = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
export function createGuildTextClient(channelId: string): DiscordClient {
|
||||
return {
|
||||
fetchChannel: async (id: string) => {
|
||||
if (id === channelId) {
|
||||
return {
|
||||
id: channelId,
|
||||
type: ChannelType.GuildText,
|
||||
name: "general",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as unknown as DiscordClient;
|
||||
}
|
||||
|
||||
export function createGuildEvent(params: {
|
||||
channelId: string;
|
||||
guildId: string;
|
||||
author: import("@buape/carbon").Message["author"];
|
||||
message: import("@buape/carbon").Message;
|
||||
}): DiscordMessageEvent {
|
||||
return {
|
||||
channel_id: params.channelId,
|
||||
guild_id: params.guildId,
|
||||
guild: {
|
||||
id: params.guildId,
|
||||
name: "Guild One",
|
||||
},
|
||||
author: params.author,
|
||||
message: params.message,
|
||||
} as unknown as DiscordMessageEvent;
|
||||
}
|
||||
|
||||
export function createDiscordMessage(params: {
|
||||
id: string;
|
||||
channelId: string;
|
||||
content: string;
|
||||
author: {
|
||||
id: string;
|
||||
bot: boolean;
|
||||
username?: string;
|
||||
};
|
||||
mentionedUsers?: Array<{ id: string }>;
|
||||
mentionedEveryone?: boolean;
|
||||
attachments?: Array<Record<string, unknown>>;
|
||||
}): import("@buape/carbon").Message {
|
||||
return {
|
||||
id: params.id,
|
||||
content: params.content,
|
||||
timestamp: new Date().toISOString(),
|
||||
channelId: params.channelId,
|
||||
attachments: params.attachments ?? [],
|
||||
mentionedUsers: params.mentionedUsers ?? [],
|
||||
mentionedRoles: [],
|
||||
mentionedEveryone: params.mentionedEveryone ?? false,
|
||||
author: params.author,
|
||||
} as unknown as import("@buape/carbon").Message;
|
||||
}
|
||||
|
||||
export function createDiscordPreflightArgs(params: {
|
||||
cfg: OpenClawConfig;
|
||||
discordConfig: DiscordConfig;
|
||||
data: DiscordMessageEvent;
|
||||
client: DiscordClient;
|
||||
botUserId?: string;
|
||||
}): Parameters<typeof preflightDiscordMessage>[0] {
|
||||
return {
|
||||
cfg: params.cfg,
|
||||
discordConfig: params.discordConfig,
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {} as import("../../runtime.js").RuntimeEnv,
|
||||
botUserId: params.botUserId ?? "openclaw-bot",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 1_000_000,
|
||||
textLimit: 2_000,
|
||||
replyToMode: "all",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: true,
|
||||
ackReactionScope: "direct",
|
||||
groupPolicy: "open",
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
data: params.data,
|
||||
client: params.client,
|
||||
};
|
||||
}
|
||||
@ -15,25 +15,21 @@ import {
|
||||
resolvePreflightMentionRequirement,
|
||||
shouldIgnoreBoundThreadWebhookMessage,
|
||||
} from "./message-handler.preflight.js";
|
||||
import {
|
||||
createDiscordMessage,
|
||||
createDiscordPreflightArgs,
|
||||
createGuildEvent,
|
||||
createGuildTextClient,
|
||||
DEFAULT_PREFLIGHT_CFG,
|
||||
type DiscordClient,
|
||||
type DiscordConfig,
|
||||
type DiscordMessageEvent,
|
||||
} from "./message-handler.preflight.test-helpers.js";
|
||||
import {
|
||||
__testing as threadBindingTesting,
|
||||
createNoopThreadBindingManager,
|
||||
createThreadBindingManager,
|
||||
} from "./thread-bindings.js";
|
||||
|
||||
type DiscordConfig = NonNullable<
|
||||
import("../../config/config.js").OpenClawConfig["channels"]
|
||||
>["discord"];
|
||||
type DiscordMessageEvent = import("./listeners.js").DiscordMessageEvent;
|
||||
type DiscordClient = import("@buape/carbon").Client;
|
||||
|
||||
const DEFAULT_CFG = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
} as import("../../config/config.js").OpenClawConfig;
|
||||
|
||||
function createThreadBinding(
|
||||
overrides?: Partial<
|
||||
import("../../infra/outbound/session-binding-service.js").SessionBindingRecord
|
||||
@ -67,41 +63,7 @@ function createPreflightArgs(params: {
|
||||
data: DiscordMessageEvent;
|
||||
client: DiscordClient;
|
||||
}): Parameters<typeof preflightDiscordMessage>[0] {
|
||||
return {
|
||||
cfg: params.cfg,
|
||||
discordConfig: params.discordConfig,
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {} as import("../../runtime.js").RuntimeEnv,
|
||||
botUserId: "openclaw-bot",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 1_000_000,
|
||||
textLimit: 2_000,
|
||||
replyToMode: "all",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: true,
|
||||
ackReactionScope: "direct",
|
||||
groupPolicy: "open",
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
data: params.data,
|
||||
client: params.client,
|
||||
};
|
||||
}
|
||||
|
||||
function createGuildTextClient(channelId: string): DiscordClient {
|
||||
return {
|
||||
fetchChannel: async (id: string) => {
|
||||
if (id === channelId) {
|
||||
return {
|
||||
id: channelId,
|
||||
type: ChannelType.GuildText,
|
||||
name: "general",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as unknown as DiscordClient;
|
||||
return createDiscordPreflightArgs(params);
|
||||
}
|
||||
|
||||
function createThreadClient(params: { threadId: string; parentId: string }): DiscordClient {
|
||||
@ -128,50 +90,6 @@ function createThreadClient(params: { threadId: string; parentId: string }): Dis
|
||||
} as unknown as DiscordClient;
|
||||
}
|
||||
|
||||
function createGuildEvent(params: {
|
||||
channelId: string;
|
||||
guildId: string;
|
||||
author: import("@buape/carbon").Message["author"];
|
||||
message: import("@buape/carbon").Message;
|
||||
}): DiscordMessageEvent {
|
||||
return {
|
||||
channel_id: params.channelId,
|
||||
guild_id: params.guildId,
|
||||
guild: {
|
||||
id: params.guildId,
|
||||
name: "Guild One",
|
||||
},
|
||||
author: params.author,
|
||||
message: params.message,
|
||||
} as unknown as DiscordMessageEvent;
|
||||
}
|
||||
|
||||
function createMessage(params: {
|
||||
id: string;
|
||||
channelId: string;
|
||||
content: string;
|
||||
author: {
|
||||
id: string;
|
||||
bot: boolean;
|
||||
username?: string;
|
||||
};
|
||||
mentionedUsers?: Array<{ id: string }>;
|
||||
mentionedEveryone?: boolean;
|
||||
attachments?: Array<Record<string, unknown>>;
|
||||
}): import("@buape/carbon").Message {
|
||||
return {
|
||||
id: params.id,
|
||||
content: params.content,
|
||||
timestamp: new Date().toISOString(),
|
||||
channelId: params.channelId,
|
||||
attachments: params.attachments ?? [],
|
||||
mentionedUsers: params.mentionedUsers ?? [],
|
||||
mentionedRoles: [],
|
||||
mentionedEveryone: params.mentionedEveryone ?? false,
|
||||
author: params.author,
|
||||
} as unknown as import("@buape/carbon").Message;
|
||||
}
|
||||
|
||||
async function runThreadBoundPreflight(params: {
|
||||
threadId: string;
|
||||
parentId: string;
|
||||
@ -197,7 +115,7 @@ async function runThreadBoundPreflight(params: {
|
||||
|
||||
return preflightDiscordMessage({
|
||||
...createPreflightArgs({
|
||||
cfg: DEFAULT_CFG,
|
||||
cfg: DEFAULT_PREFLIGHT_CFG,
|
||||
discordConfig: params.discordConfig,
|
||||
data: createGuildEvent({
|
||||
channelId: params.threadId,
|
||||
@ -223,7 +141,7 @@ async function runGuildPreflight(params: {
|
||||
}) {
|
||||
return preflightDiscordMessage({
|
||||
...createPreflightArgs({
|
||||
cfg: params.cfg ?? DEFAULT_CFG,
|
||||
cfg: params.cfg ?? DEFAULT_PREFLIGHT_CFG,
|
||||
discordConfig: params.discordConfig,
|
||||
data: createGuildEvent({
|
||||
channelId: params.channelId,
|
||||
@ -237,6 +155,40 @@ async function runGuildPreflight(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function runMentionOnlyBotPreflight(params: {
|
||||
channelId: string;
|
||||
guildId: string;
|
||||
message: import("@buape/carbon").Message;
|
||||
}) {
|
||||
return runGuildPreflight({
|
||||
channelId: params.channelId,
|
||||
guildId: params.guildId,
|
||||
message: params.message,
|
||||
discordConfig: {
|
||||
allowBots: "mentions",
|
||||
} as DiscordConfig,
|
||||
});
|
||||
}
|
||||
|
||||
async function runIgnoreOtherMentionsPreflight(params: {
|
||||
channelId: string;
|
||||
guildId: string;
|
||||
message: import("@buape/carbon").Message;
|
||||
}) {
|
||||
return runGuildPreflight({
|
||||
channelId: params.channelId,
|
||||
guildId: params.guildId,
|
||||
message: params.message,
|
||||
discordConfig: {} as DiscordConfig,
|
||||
guildEntries: {
|
||||
[params.guildId]: {
|
||||
requireMention: false,
|
||||
ignoreOtherMentions: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolvePreflightMentionRequirement", () => {
|
||||
it("requires mention when config requires mention and thread is not bound", () => {
|
||||
expect(
|
||||
@ -279,7 +231,7 @@ describe("preflightDiscordMessage", () => {
|
||||
});
|
||||
const threadId = "thread-system-1";
|
||||
const parentId = "channel-parent-1";
|
||||
const message = createMessage({
|
||||
const message = createDiscordMessage({
|
||||
id: "m-system-1",
|
||||
channelId: threadId,
|
||||
content:
|
||||
@ -311,7 +263,7 @@ describe("preflightDiscordMessage", () => {
|
||||
});
|
||||
const threadId = "thread-bot-regular-1";
|
||||
const parentId = "channel-parent-regular-1";
|
||||
const message = createMessage({
|
||||
const message = createDiscordMessage({
|
||||
id: "m-bot-regular-1",
|
||||
channelId: threadId,
|
||||
content: "here is tool output chunk",
|
||||
@ -342,7 +294,7 @@ describe("preflightDiscordMessage", () => {
|
||||
const threadId = "thread-bot-focus";
|
||||
const parentId = "channel-parent-focus";
|
||||
const client = createThreadClient({ threadId, parentId });
|
||||
const message = createMessage({
|
||||
const message = createDiscordMessage({
|
||||
id: "m-bot-1",
|
||||
channelId: threadId,
|
||||
content: "relay message without mention",
|
||||
@ -363,7 +315,7 @@ describe("preflightDiscordMessage", () => {
|
||||
const result = await preflightDiscordMessage(
|
||||
createPreflightArgs({
|
||||
cfg: {
|
||||
...DEFAULT_CFG,
|
||||
...DEFAULT_PREFLIGHT_CFG,
|
||||
} as import("../../config/config.js").OpenClawConfig,
|
||||
discordConfig: {
|
||||
allowBots: true,
|
||||
@ -386,7 +338,7 @@ describe("preflightDiscordMessage", () => {
|
||||
it("drops bot messages without mention when allowBots=mentions", async () => {
|
||||
const channelId = "channel-bot-mentions-off";
|
||||
const guildId = "guild-bot-mentions-off";
|
||||
const message = createMessage({
|
||||
const message = createDiscordMessage({
|
||||
id: "m-bot-mentions-off",
|
||||
channelId,
|
||||
content: "relay chatter",
|
||||
@ -397,14 +349,7 @@ describe("preflightDiscordMessage", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runGuildPreflight({
|
||||
channelId,
|
||||
guildId,
|
||||
message,
|
||||
discordConfig: {
|
||||
allowBots: "mentions",
|
||||
} as DiscordConfig,
|
||||
});
|
||||
const result = await runMentionOnlyBotPreflight({ channelId, guildId, message });
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@ -412,7 +357,7 @@ describe("preflightDiscordMessage", () => {
|
||||
it("allows bot messages with explicit mention when allowBots=mentions", async () => {
|
||||
const channelId = "channel-bot-mentions-on";
|
||||
const guildId = "guild-bot-mentions-on";
|
||||
const message = createMessage({
|
||||
const message = createDiscordMessage({
|
||||
id: "m-bot-mentions-on",
|
||||
channelId,
|
||||
content: "hi <@openclaw-bot>",
|
||||
@ -424,14 +369,7 @@ describe("preflightDiscordMessage", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runGuildPreflight({
|
||||
channelId,
|
||||
guildId,
|
||||
message,
|
||||
discordConfig: {
|
||||
allowBots: "mentions",
|
||||
} as DiscordConfig,
|
||||
});
|
||||
const result = await runMentionOnlyBotPreflight({ channelId, guildId, message });
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
@ -439,7 +377,7 @@ describe("preflightDiscordMessage", () => {
|
||||
it("drops guild messages that mention another user when ignoreOtherMentions=true", async () => {
|
||||
const channelId = "channel-other-mention-1";
|
||||
const guildId = "guild-other-mention-1";
|
||||
const message = createMessage({
|
||||
const message = createDiscordMessage({
|
||||
id: "m-other-mention-1",
|
||||
channelId,
|
||||
content: "hello <@999>",
|
||||
@ -451,18 +389,7 @@ describe("preflightDiscordMessage", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runGuildPreflight({
|
||||
channelId,
|
||||
guildId,
|
||||
message,
|
||||
discordConfig: {} as DiscordConfig,
|
||||
guildEntries: {
|
||||
[guildId]: {
|
||||
requireMention: false,
|
||||
ignoreOtherMentions: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await runIgnoreOtherMentionsPreflight({ channelId, guildId, message });
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@ -470,7 +397,7 @@ describe("preflightDiscordMessage", () => {
|
||||
it("does not drop @everyone messages when ignoreOtherMentions=true", async () => {
|
||||
const channelId = "channel-other-mention-everyone";
|
||||
const guildId = "guild-other-mention-everyone";
|
||||
const message = createMessage({
|
||||
const message = createDiscordMessage({
|
||||
id: "m-other-mention-everyone",
|
||||
channelId,
|
||||
content: "@everyone heads up",
|
||||
@ -482,18 +409,7 @@ describe("preflightDiscordMessage", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runGuildPreflight({
|
||||
channelId,
|
||||
guildId,
|
||||
message,
|
||||
discordConfig: {} as DiscordConfig,
|
||||
guildEntries: {
|
||||
[guildId]: {
|
||||
requireMention: false,
|
||||
ignoreOtherMentions: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await runIgnoreOtherMentionsPreflight({ channelId, guildId, message });
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.hasAnyMention).toBe(true);
|
||||
@ -503,7 +419,7 @@ describe("preflightDiscordMessage", () => {
|
||||
const channelId = "channel-everyone-1";
|
||||
const guildId = "guild-everyone-1";
|
||||
const client = createGuildTextClient(channelId);
|
||||
const message = createMessage({
|
||||
const message = createDiscordMessage({
|
||||
id: "m-everyone-1",
|
||||
channelId,
|
||||
content: "@everyone heads up",
|
||||
@ -517,7 +433,7 @@ describe("preflightDiscordMessage", () => {
|
||||
|
||||
const result = await preflightDiscordMessage({
|
||||
...createPreflightArgs({
|
||||
cfg: DEFAULT_CFG,
|
||||
cfg: DEFAULT_PREFLIGHT_CFG,
|
||||
discordConfig: {
|
||||
allowBots: true,
|
||||
} as DiscordConfig,
|
||||
@ -546,7 +462,7 @@ describe("preflightDiscordMessage", () => {
|
||||
const channelId = "channel-audio-1";
|
||||
const client = createGuildTextClient(channelId);
|
||||
|
||||
const message = createMessage({
|
||||
const message = createDiscordMessage({
|
||||
id: "m-audio-1",
|
||||
channelId,
|
||||
content: "",
|
||||
@ -568,7 +484,7 @@ describe("preflightDiscordMessage", () => {
|
||||
const result = await preflightDiscordMessage(
|
||||
createPreflightArgs({
|
||||
cfg: {
|
||||
...DEFAULT_CFG,
|
||||
...DEFAULT_PREFLIGHT_CFG,
|
||||
messages: {
|
||||
groupChat: {
|
||||
mentionPatterns: ["openclaw"],
|
||||
|
||||
@ -16,7 +16,7 @@ export type RuntimeEnv = import("../../runtime.js").RuntimeEnv;
|
||||
|
||||
export type DiscordMessageEvent = import("./listeners.js").DiscordMessageEvent;
|
||||
|
||||
export type DiscordMessagePreflightContext = {
|
||||
type DiscordMessagePreflightSharedFields = {
|
||||
cfg: LoadedConfig;
|
||||
discordConfig: NonNullable<
|
||||
import("../../config/config.js").OpenClawConfig["channels"]
|
||||
@ -33,7 +33,9 @@ export type DiscordMessagePreflightContext = {
|
||||
replyToMode: ReplyToMode;
|
||||
ackReactionScope: "all" | "direct" | "group-all" | "group-mentions" | "off" | "none";
|
||||
groupPolicy: "open" | "disabled" | "allowlist";
|
||||
};
|
||||
|
||||
export type DiscordMessagePreflightContext = DiscordMessagePreflightSharedFields & {
|
||||
data: DiscordMessageEvent;
|
||||
client: Client;
|
||||
message: DiscordMessageEvent["message"];
|
||||
@ -89,19 +91,7 @@ export type DiscordMessagePreflightContext = {
|
||||
discordRestFetch?: typeof fetch;
|
||||
};
|
||||
|
||||
export type DiscordMessagePreflightParams = {
|
||||
cfg: LoadedConfig;
|
||||
discordConfig: DiscordMessagePreflightContext["discordConfig"];
|
||||
accountId: string;
|
||||
token: string;
|
||||
runtime: RuntimeEnv;
|
||||
botUserId?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
guildHistories: Map<string, HistoryEntry[]>;
|
||||
historyLimit: number;
|
||||
mediaMaxBytes: number;
|
||||
textLimit: number;
|
||||
replyToMode: ReplyToMode;
|
||||
export type DiscordMessagePreflightParams = DiscordMessagePreflightSharedFields & {
|
||||
dmEnabled: boolean;
|
||||
groupDmEnabled: boolean;
|
||||
groupDmChannels?: string[];
|
||||
|
||||
@ -1,24 +1,17 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createDiscordMessageHandler,
|
||||
preflightDiscordMessageMock,
|
||||
processDiscordMessageMock,
|
||||
} from "./message-handler.module-test-helpers.js";
|
||||
import {
|
||||
createDiscordHandlerParams,
|
||||
createDiscordPreflightContext,
|
||||
} from "./message-handler.test-helpers.js";
|
||||
|
||||
const preflightDiscordMessageMock = vi.hoisted(() => vi.fn());
|
||||
const processDiscordMessageMock = vi.hoisted(() => vi.fn());
|
||||
const eventualReplyDeliveredMock = vi.hoisted(() => vi.fn());
|
||||
type SetStatusFn = (patch: Record<string, unknown>) => void;
|
||||
|
||||
vi.mock("./message-handler.preflight.js", () => ({
|
||||
preflightDiscordMessage: preflightDiscordMessageMock,
|
||||
}));
|
||||
|
||||
vi.mock("./message-handler.process.js", () => ({
|
||||
processDiscordMessage: processDiscordMessageMock,
|
||||
}));
|
||||
|
||||
const { createDiscordMessageHandler } = await import("./message-handler.js");
|
||||
|
||||
function createDeferred<T = void>() {
|
||||
let resolve: (value: T | PromiseLike<T>) => void = () => {};
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
@ -45,20 +38,30 @@ function createPreflightContext(channelId = "ch-1") {
|
||||
return createDiscordPreflightContext(channelId);
|
||||
}
|
||||
|
||||
function createHandlerWithDefaultPreflight(overrides?: {
|
||||
setStatus?: SetStatusFn;
|
||||
workerRunTimeoutMs?: number;
|
||||
}) {
|
||||
preflightDiscordMessageMock.mockImplementation(async (params: { data: { channel_id: string } }) =>
|
||||
createPreflightContext(params.data.channel_id),
|
||||
);
|
||||
return createDiscordMessageHandler(createDiscordHandlerParams(overrides));
|
||||
}
|
||||
|
||||
async function createLifecycleStopScenario(params: {
|
||||
createHandler: (status: SetStatusFn) => {
|
||||
handler: (data: never, opts: never) => Promise<void>;
|
||||
stop: () => void;
|
||||
};
|
||||
}) {
|
||||
preflightDiscordMessageMock.mockImplementation(
|
||||
async (preflightParams: { data: { channel_id: string } }) =>
|
||||
createPreflightContext(preflightParams.data.channel_id),
|
||||
);
|
||||
const runInFlight = createDeferred();
|
||||
processDiscordMessageMock.mockImplementation(async () => {
|
||||
await runInFlight.promise;
|
||||
});
|
||||
preflightDiscordMessageMock.mockImplementation(
|
||||
async (contextParams: { data: { channel_id: string } }) =>
|
||||
createPreflightContext(contextParams.data.channel_id),
|
||||
);
|
||||
|
||||
const setStatus = vi.fn<SetStatusFn>();
|
||||
const { handler, stop } = params.createHandler(setStatus);
|
||||
@ -111,13 +114,8 @@ describe("createDiscordMessageHandler queue behavior", () => {
|
||||
.mockImplementationOnce(async () => {
|
||||
await secondRun.promise;
|
||||
});
|
||||
preflightDiscordMessageMock.mockImplementation(
|
||||
async (params: { data: { channel_id: string } }) =>
|
||||
createPreflightContext(params.data.channel_id),
|
||||
);
|
||||
|
||||
const setStatus = vi.fn();
|
||||
const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus }));
|
||||
const handler = createHandlerWithDefaultPreflight({ setStatus });
|
||||
|
||||
await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined();
|
||||
|
||||
@ -175,12 +173,11 @@ describe("createDiscordMessageHandler queue behavior", () => {
|
||||
});
|
||||
})
|
||||
.mockImplementationOnce(async () => undefined);
|
||||
preflightDiscordMessageMock.mockImplementation(
|
||||
async (params: { data: { channel_id: string } }) =>
|
||||
createPreflightContext(params.data.channel_id),
|
||||
);
|
||||
|
||||
const params = createDiscordHandlerParams({ workerRunTimeoutMs: 50 });
|
||||
preflightDiscordMessageMock.mockImplementation(
|
||||
async (preflightParams: { data: { channel_id: string } }) =>
|
||||
createPreflightContext(preflightParams.data.channel_id),
|
||||
);
|
||||
const handler = createDiscordMessageHandler(params);
|
||||
|
||||
await expect(
|
||||
@ -226,13 +223,8 @@ describe("createDiscordMessageHandler queue behavior", () => {
|
||||
});
|
||||
},
|
||||
);
|
||||
preflightDiscordMessageMock.mockImplementation(
|
||||
async (params: { data: { channel_id: string } }) =>
|
||||
createPreflightContext(params.data.channel_id),
|
||||
);
|
||||
|
||||
const params = createDiscordHandlerParams({ workerRunTimeoutMs: 0 });
|
||||
const handler = createDiscordMessageHandler(params);
|
||||
const handler = createHandlerWithDefaultPreflight({ workerRunTimeoutMs: 0 });
|
||||
|
||||
await expect(
|
||||
handler(createMessageData("m-1") as never, {} as never),
|
||||
@ -442,7 +434,7 @@ describe("createDiscordMessageHandler queue behavior", () => {
|
||||
);
|
||||
|
||||
const setStatus = vi.fn();
|
||||
const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus }));
|
||||
const handler = createHandlerWithDefaultPreflight({ setStatus });
|
||||
|
||||
await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined();
|
||||
await expect(handler(createMessageData("m-2") as never, {} as never)).resolves.toBeUndefined();
|
||||
|
||||
@ -173,30 +173,13 @@ describe("resolveForwardedMediaList", () => {
|
||||
512,
|
||||
);
|
||||
|
||||
expect(fetchRemoteMedia).toHaveBeenCalledTimes(1);
|
||||
const call = fetchRemoteMedia.mock.calls[0]?.[0] as {
|
||||
url?: string;
|
||||
filePathHint?: string;
|
||||
maxBytes?: number;
|
||||
fetchImpl?: unknown;
|
||||
ssrfPolicy?: unknown;
|
||||
};
|
||||
expect(call).toMatchObject({
|
||||
url: attachment.url,
|
||||
expectSinglePngDownload({
|
||||
result,
|
||||
expectedUrl: attachment.url,
|
||||
filePathHint: attachment.filename,
|
||||
maxBytes: 512,
|
||||
fetchImpl: undefined,
|
||||
expectedPath: "/tmp/image.png",
|
||||
placeholder: "<media:image>",
|
||||
});
|
||||
expectDiscordCdnSsrFPolicy(call.ssrfPolicy);
|
||||
expect(saveMediaBuffer).toHaveBeenCalledTimes(1);
|
||||
expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
path: "/tmp/image.png",
|
||||
contentType: "image/png",
|
||||
placeholder: "<media:image>",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards fetchImpl to forwarded attachment downloads", async () => {
|
||||
|
||||
@ -130,6 +130,25 @@ function expectBoundSessionDispatch(
|
||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
|
||||
async function expectBoundStatusCommandDispatch(params: {
|
||||
cfg: OpenClawConfig;
|
||||
interaction: MockCommandInteraction;
|
||||
channelId: string;
|
||||
boundSessionKey: string;
|
||||
}) {
|
||||
const command = createStatusCommand(params.cfg);
|
||||
setConfiguredBinding(params.channelId, params.boundSessionKey);
|
||||
|
||||
vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null);
|
||||
const dispatchSpy = createDispatchSpy();
|
||||
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(
|
||||
params.interaction as unknown,
|
||||
);
|
||||
|
||||
expectBoundSessionDispatch(dispatchSpy, params.boundSessionKey);
|
||||
}
|
||||
|
||||
describe("Discord native plugin command dispatch", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@ -212,7 +231,6 @@ describe("Discord native plugin command dispatch", () => {
|
||||
},
|
||||
],
|
||||
} as OpenClawConfig;
|
||||
const command = createStatusCommand(cfg);
|
||||
const interaction = createInteraction({
|
||||
channelType: ChannelType.GuildText,
|
||||
channelId,
|
||||
@ -220,14 +238,12 @@ describe("Discord native plugin command dispatch", () => {
|
||||
guildName: "Ops",
|
||||
});
|
||||
|
||||
setConfiguredBinding(channelId, boundSessionKey);
|
||||
|
||||
vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null);
|
||||
const dispatchSpy = createDispatchSpy();
|
||||
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
|
||||
|
||||
expectBoundSessionDispatch(dispatchSpy, boundSessionKey);
|
||||
await expectBoundStatusCommandDispatch({
|
||||
cfg,
|
||||
interaction,
|
||||
channelId,
|
||||
boundSessionKey,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the routed slash and channel session keys when no bound session exists", async () => {
|
||||
@ -312,19 +328,16 @@ describe("Discord native plugin command dispatch", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const command = createStatusCommand(cfg);
|
||||
const interaction = createInteraction({
|
||||
channelType: ChannelType.DM,
|
||||
channelId,
|
||||
});
|
||||
|
||||
setConfiguredBinding(channelId, boundSessionKey);
|
||||
|
||||
vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null);
|
||||
const dispatchSpy = createDispatchSpy();
|
||||
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
|
||||
|
||||
expectBoundSessionDispatch(dispatchSpy, boundSessionKey);
|
||||
await expectBoundStatusCommandDispatch({
|
||||
cfg,
|
||||
interaction,
|
||||
channelId,
|
||||
boundSessionKey,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user