mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-05 22:32:12 +00:00
test(plugins): finish moving contract coverage
This commit is contained in:
@@ -11,22 +11,34 @@ import { clearSessionStoreCacheForTest } from "openclaw/plugin-sdk/config-runtim
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
type ConversationRuntimeModule = typeof import("openclaw/plugin-sdk/conversation-runtime");
|
||||
type ResolveConfiguredBindingRoute = ConversationRuntimeModule["resolveConfiguredBindingRoute"];
|
||||
type ConfiguredBindingRouteResult = ReturnType<ResolveConfiguredBindingRoute>;
|
||||
type EnsureConfiguredBindingRouteReady =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
|
||||
type ResolveConfiguredBindingRoute =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute;
|
||||
ConversationRuntimeModule["ensureConfiguredBindingRouteReady"];
|
||||
|
||||
function createUnboundConfiguredRouteResult(): ConfiguredBindingRouteResult {
|
||||
return {
|
||||
bindingResolution: null,
|
||||
route: {
|
||||
agentId: "main",
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
sessionKey: SESSION_KEY,
|
||||
mainSessionKey: SESSION_KEY,
|
||||
lastRoutePolicy: "main",
|
||||
matchedBy: "default",
|
||||
},
|
||||
};
|
||||
}
|
||||
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() =>
|
||||
vi.fn<EnsureConfiguredBindingRouteReady>(async () => ({ ok: true })),
|
||||
);
|
||||
const resolveConfiguredBindingRouteMock = vi.hoisted(() =>
|
||||
vi.fn<ResolveConfiguredBindingRoute>(({ route }) => ({
|
||||
bindingResolution: null,
|
||||
route,
|
||||
})),
|
||||
vi.fn<ResolveConfiguredBindingRoute>(() => createUnboundConfiguredRouteResult()),
|
||||
);
|
||||
|
||||
type ConfiguredBindingRoute = ReturnType<ResolveConfiguredBindingRoute>;
|
||||
type ConfiguredBindingRoute = ConfiguredBindingRouteResult;
|
||||
type ConfiguredBindingResolution = NonNullable<ConfiguredBindingRoute["bindingResolution"]>;
|
||||
|
||||
function createConfiguredRouteResult(
|
||||
@@ -35,6 +47,11 @@ function createConfiguredRouteResult(
|
||||
return {
|
||||
bindingResolution: {
|
||||
record: {
|
||||
bindingId: "binding-1",
|
||||
targetSessionKey: SESSION_KEY,
|
||||
targetKind: "session",
|
||||
status: "active",
|
||||
boundAt: Date.now(),
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
@@ -87,10 +104,7 @@ describe("discord native /think autocomplete", () => {
|
||||
ensureConfiguredBindingRouteReadyMock.mockReset();
|
||||
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });
|
||||
resolveConfiguredBindingRouteMock.mockReset();
|
||||
resolveConfiguredBindingRouteMock.mockImplementation(({ route }) => ({
|
||||
bindingResolution: null,
|
||||
route,
|
||||
}));
|
||||
resolveConfiguredBindingRouteMock.mockReturnValue(createUnboundConfiguredRouteResult());
|
||||
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
STORE_PATH,
|
||||
|
||||
28
extensions/imessage/src/normalize.test.ts
Normal file
28
extensions/imessage/src/normalize.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "./normalize.js";
|
||||
|
||||
describe("normalizeIMessageMessagingTarget", () => {
|
||||
it("normalizes blank inputs to undefined", () => {
|
||||
expect(normalizeIMessageMessagingTarget(" ")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves service prefixes for handles", () => {
|
||||
expect(normalizeIMessageMessagingTarget("sms:+1 (555) 222-3333")).toBe("sms:+15552223333");
|
||||
});
|
||||
|
||||
it("drops service prefixes for chat targets", () => {
|
||||
expect(normalizeIMessageMessagingTarget("sms:chat_id:123")).toBe("chat_id:123");
|
||||
expect(normalizeIMessageMessagingTarget("imessage:CHAT_GUID:abc")).toBe("chat_guid:abc");
|
||||
expect(normalizeIMessageMessagingTarget("auto:ChatIdentifier:foo")).toBe("chatidentifier:foo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeIMessageTargetId", () => {
|
||||
it("detects common iMessage target forms", () => {
|
||||
expect(looksLikeIMessageTargetId("sms:+15555550123")).toBe(true);
|
||||
expect(looksLikeIMessageTargetId("chat_id:123")).toBe(true);
|
||||
expect(looksLikeIMessageTargetId("user@example.com")).toBe(true);
|
||||
expect(looksLikeIMessageTargetId("+15555550123")).toBe(true);
|
||||
expect(looksLikeIMessageTargetId("")).toBe(false);
|
||||
});
|
||||
});
|
||||
20
extensions/imessage/src/test-plugin.test.ts
Normal file
20
extensions/imessage/src/test-plugin.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
listImportedBundledPluginFacadeIds,
|
||||
resetFacadeRuntimeStateForTest,
|
||||
} from "../../../src/plugin-sdk/facade-runtime.js";
|
||||
import { createIMessageTestPlugin } from "./test-plugin.js";
|
||||
|
||||
afterEach(() => {
|
||||
resetFacadeRuntimeStateForTest();
|
||||
});
|
||||
|
||||
describe("createIMessageTestPlugin", () => {
|
||||
it("does not load the bundled iMessage facade by default", () => {
|
||||
expect(listImportedBundledPluginFacadeIds()).toEqual([]);
|
||||
|
||||
createIMessageTestPlugin();
|
||||
|
||||
expect(listImportedBundledPluginFacadeIds()).toEqual([]);
|
||||
});
|
||||
});
|
||||
387
extensions/line/src/reply-payload-transform.test.ts
Normal file
387
extensions/line/src/reply-payload-transform.test.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasLineDirectives, parseLineDirectives } from "./reply-payload-transform.js";
|
||||
|
||||
const getLineData = (result: ReturnType<typeof parseLineDirectives>) =>
|
||||
(result.channelData?.line as Record<string, unknown> | undefined) ?? {};
|
||||
|
||||
describe("hasLineDirectives", () => {
|
||||
it("matches expected detection across directive patterns", () => {
|
||||
const cases: Array<{ text: string; expected: boolean }> = [
|
||||
{ text: "Here are options [[quick_replies: A, B, C]]", expected: true },
|
||||
{ text: "[[location: Place | Address | 35.6 | 139.7]]", expected: true },
|
||||
{ text: "[[confirm: Continue? | Yes | No]]", expected: true },
|
||||
{ text: "[[buttons: Menu | Choose | Opt1:data1, Opt2:data2]]", expected: true },
|
||||
{ text: "Just regular text", expected: false },
|
||||
{ text: "[[not_a_directive: something]]", expected: false },
|
||||
{ text: "[[media_player: Song | Artist | Speaker]]", expected: true },
|
||||
{ text: "[[event: Meeting | Jan 24 | 2pm]]", expected: true },
|
||||
{ text: "[[agenda: Today | Meeting:9am, Lunch:12pm]]", expected: true },
|
||||
{ text: "[[device: TV | Room]]", expected: true },
|
||||
{ text: "[[appletv_remote: Apple TV | Playing]]", expected: true },
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
expect(hasLineDirectives(testCase.text)).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLineDirectives", () => {
|
||||
describe("quick_replies", () => {
|
||||
it("parses quick replies variants", () => {
|
||||
const cases: Array<{
|
||||
text: string;
|
||||
channelData?: { line: { quickReplies: string[] } };
|
||||
quickReplies: string[];
|
||||
outputText?: string;
|
||||
}> = [
|
||||
{
|
||||
text: "Choose one:\n[[quick_replies: Option A, Option B, Option C]]",
|
||||
quickReplies: ["Option A", "Option B", "Option C"],
|
||||
outputText: "Choose one:",
|
||||
},
|
||||
{
|
||||
text: "Before [[quick_replies: A, B]] After",
|
||||
quickReplies: ["A", "B"],
|
||||
outputText: "Before After",
|
||||
},
|
||||
{
|
||||
text: "Text [[quick_replies: C, D]]",
|
||||
channelData: { line: { quickReplies: ["A", "B"] } },
|
||||
quickReplies: ["A", "B", "C", "D"],
|
||||
outputText: "Text",
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({
|
||||
text: testCase.text,
|
||||
channelData: testCase.channelData,
|
||||
});
|
||||
expect(getLineData(result).quickReplies).toEqual(testCase.quickReplies);
|
||||
if (testCase.outputText !== undefined) {
|
||||
expect(result.text).toBe(testCase.outputText);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("location", () => {
|
||||
it("parses location variants", () => {
|
||||
const existing = { title: "Existing", address: "Addr", latitude: 1, longitude: 2 };
|
||||
const cases: Array<{
|
||||
text: string;
|
||||
channelData?: { line: { location: typeof existing } };
|
||||
location?: typeof existing;
|
||||
outputText?: string;
|
||||
}> = [
|
||||
{
|
||||
text: "Here's the location:\n[[location: Tokyo Station | Tokyo, Japan | 35.6812 | 139.7671]]",
|
||||
location: {
|
||||
title: "Tokyo Station",
|
||||
address: "Tokyo, Japan",
|
||||
latitude: 35.6812,
|
||||
longitude: 139.7671,
|
||||
},
|
||||
outputText: "Here's the location:",
|
||||
},
|
||||
{
|
||||
text: "[[location: Place | Address | invalid | 139.7]]",
|
||||
location: undefined,
|
||||
},
|
||||
{
|
||||
text: "[[location: New | New Addr | 35.6 | 139.7]]",
|
||||
channelData: { line: { location: existing } },
|
||||
location: existing,
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({
|
||||
text: testCase.text,
|
||||
channelData: testCase.channelData,
|
||||
});
|
||||
expect(getLineData(result).location).toEqual(testCase.location);
|
||||
if (testCase.outputText !== undefined) {
|
||||
expect(result.text).toBe(testCase.outputText);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirm", () => {
|
||||
it("parses confirm directives with default and custom action payloads", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "default yes/no data",
|
||||
text: "[[confirm: Delete this item? | Yes | No]]",
|
||||
expectedTemplate: {
|
||||
type: "confirm",
|
||||
text: "Delete this item?",
|
||||
confirmLabel: "Yes",
|
||||
confirmData: "yes",
|
||||
cancelLabel: "No",
|
||||
cancelData: "no",
|
||||
altText: "Delete this item?",
|
||||
},
|
||||
expectedText: undefined,
|
||||
},
|
||||
{
|
||||
name: "custom action data",
|
||||
text: "[[confirm: Proceed? | OK:action=confirm | Cancel:action=cancel]]",
|
||||
expectedTemplate: {
|
||||
type: "confirm",
|
||||
text: "Proceed?",
|
||||
confirmLabel: "OK",
|
||||
confirmData: "action=confirm",
|
||||
cancelLabel: "Cancel",
|
||||
cancelData: "action=cancel",
|
||||
altText: "Proceed?",
|
||||
},
|
||||
expectedText: undefined,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
expect(getLineData(result).templateMessage, testCase.name).toEqual(
|
||||
testCase.expectedTemplate,
|
||||
);
|
||||
expect(result.text, testCase.name).toBe(testCase.expectedText);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buttons", () => {
|
||||
it("parses message/uri/postback button actions and enforces action caps", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "message actions",
|
||||
text: "[[buttons: Menu | Select an option | Help:/help, Status:/status]]",
|
||||
expectedTemplate: {
|
||||
type: "buttons",
|
||||
title: "Menu",
|
||||
text: "Select an option",
|
||||
actions: [
|
||||
{ type: "message", label: "Help", data: "/help" },
|
||||
{ type: "message", label: "Status", data: "/status" },
|
||||
],
|
||||
altText: "Menu: Select an option",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "uri action",
|
||||
text: "[[buttons: Links | Visit us | Site:https://example.com]]",
|
||||
expectedFirstAction: {
|
||||
type: "uri",
|
||||
label: "Site",
|
||||
uri: "https://example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "postback action",
|
||||
text: "[[buttons: Actions | Choose | Select:action=select&id=1]]",
|
||||
expectedFirstAction: {
|
||||
type: "postback",
|
||||
label: "Select",
|
||||
data: "action=select&id=1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "action cap",
|
||||
text: "[[buttons: Menu | Text | A:a, B:b, C:c, D:d, E:e, F:f]]",
|
||||
expectedActionCount: 4,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const templateMessage = getLineData(result).templateMessage as {
|
||||
type?: string;
|
||||
actions?: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(templateMessage?.type, testCase.name).toBe("buttons");
|
||||
if ("expectedTemplate" in testCase) {
|
||||
expect(templateMessage, testCase.name).toEqual(testCase.expectedTemplate);
|
||||
}
|
||||
if ("expectedFirstAction" in testCase) {
|
||||
expect(templateMessage?.actions?.[0], testCase.name).toEqual(
|
||||
testCase.expectedFirstAction,
|
||||
);
|
||||
}
|
||||
if ("expectedActionCount" in testCase) {
|
||||
expect(templateMessage?.actions?.length, testCase.name).toBe(
|
||||
testCase.expectedActionCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("media_player", () => {
|
||||
it("parses media_player directives across full/minimal/paused variants", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "all fields",
|
||||
text: "Now playing:\n[[media_player: Bohemian Rhapsody | Queen | Speaker | https://example.com/album.jpg | playing]]",
|
||||
expectedAltText: "🎵 Bohemian Rhapsody - Queen",
|
||||
expectedText: "Now playing:",
|
||||
expectFooter: true,
|
||||
expectBodyContents: false,
|
||||
},
|
||||
{
|
||||
name: "minimal",
|
||||
text: "[[media_player: Unknown Track]]",
|
||||
expectedAltText: "🎵 Unknown Track",
|
||||
expectedText: undefined,
|
||||
expectFooter: false,
|
||||
expectBodyContents: false,
|
||||
},
|
||||
{
|
||||
name: "paused status",
|
||||
text: "[[media_player: Song | Artist | Player | | paused]]",
|
||||
expectedAltText: undefined,
|
||||
expectedText: undefined,
|
||||
expectFooter: false,
|
||||
expectBodyContents: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const flexMessage = getLineData(result).flexMessage as {
|
||||
altText?: string;
|
||||
contents?: { footer?: { contents?: unknown[] }; body?: { contents?: unknown[] } };
|
||||
};
|
||||
expect(flexMessage, testCase.name).toBeDefined();
|
||||
if (testCase.expectedAltText !== undefined) {
|
||||
expect(flexMessage?.altText, testCase.name).toBe(testCase.expectedAltText);
|
||||
}
|
||||
if (testCase.expectedText !== undefined) {
|
||||
expect(result.text, testCase.name).toBe(testCase.expectedText);
|
||||
}
|
||||
if (testCase.expectFooter) {
|
||||
expect(flexMessage?.contents?.footer?.contents?.length, testCase.name).toBeGreaterThan(0);
|
||||
}
|
||||
if ("expectBodyContents" in testCase && testCase.expectBodyContents) {
|
||||
expect(flexMessage?.contents?.body?.contents, testCase.name).toBeDefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("event", () => {
|
||||
it("parses event variants", () => {
|
||||
const cases = [
|
||||
{
|
||||
text: "[[event: Team Meeting | January 24, 2026 | 2:00 PM - 3:00 PM | Conference Room A | Discuss Q1 roadmap]]",
|
||||
altText: "📅 Team Meeting - January 24, 2026 2:00 PM - 3:00 PM",
|
||||
},
|
||||
{
|
||||
text: "[[event: Birthday Party | March 15]]",
|
||||
altText: "📅 Birthday Party - March 15",
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe(testCase.altText);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("agenda", () => {
|
||||
it("parses agenda variants", () => {
|
||||
const cases = [
|
||||
{
|
||||
text: "[[agenda: Today's Schedule | Team Meeting:9:00 AM, Lunch:12:00 PM, Review:3:00 PM]]",
|
||||
altText: "📋 Today's Schedule (3 events)",
|
||||
},
|
||||
{
|
||||
text: "[[agenda: Tasks | Buy groceries, Call mom, Workout]]",
|
||||
altText: "📋 Tasks (3 events)",
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe(testCase.altText);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("device", () => {
|
||||
it("parses device variants", () => {
|
||||
const cases = [
|
||||
{
|
||||
text: "[[device: TV | Streaming Box | Playing | Play/Pause:toggle, Menu:menu]]",
|
||||
altText: "📱 TV: Playing",
|
||||
},
|
||||
{
|
||||
text: "[[device: Speaker]]",
|
||||
altText: "📱 Speaker",
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe(testCase.altText);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("appletv_remote", () => {
|
||||
it("parses appletv remote variants", () => {
|
||||
const cases = [
|
||||
{
|
||||
text: "[[appletv_remote: Apple TV | Playing]]",
|
||||
contains: "Apple TV",
|
||||
},
|
||||
{
|
||||
text: "[[appletv_remote: Apple TV]]",
|
||||
contains: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
if (testCase.contains) {
|
||||
expect(flexMessage?.altText).toContain(testCase.contains);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("combined directives", () => {
|
||||
it("handles text with no directives", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Just plain text here",
|
||||
});
|
||||
|
||||
expect(result.text).toBe("Just plain text here");
|
||||
expect(getLineData(result).quickReplies).toBeUndefined();
|
||||
expect(getLineData(result).location).toBeUndefined();
|
||||
expect(getLineData(result).templateMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves other payload fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Hello [[quick_replies: A, B]]",
|
||||
mediaUrl: "https://example.com/image.jpg",
|
||||
replyToId: "msg123",
|
||||
});
|
||||
|
||||
expect(result.mediaUrl).toBe("https://example.com/image.jpg");
|
||||
expect(result.replyToId).toBe("msg123");
|
||||
expect(getLineData(result).quickReplies).toEqual(["A", "B"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,142 +1,124 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findMatrixAccountEntry,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "./account-selection.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import { getMatrixScopedEnvVarNames } from "./env-vars.js";
|
||||
|
||||
describe("Matrix account selection topology", () => {
|
||||
it("includes a top-level default account when its auth is actually complete", () => {
|
||||
const cfg = {
|
||||
describe("matrix account selection", () => {
|
||||
it("resolves configured account ids from non-canonical account keys", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accessToken: "default-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
"Team Ops": { homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg)).toEqual(["team-ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops");
|
||||
});
|
||||
|
||||
it("matches the default account against normalized Matrix account keys", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "Team Ops",
|
||||
accounts: {
|
||||
"Ops Bot": { homeserver: "https://matrix.example.org" },
|
||||
"Team Ops": { homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires an explicit default when multiple Matrix accounts exist without one", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: { homeserver: "https://matrix.example.org" },
|
||||
alerts: { homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(true);
|
||||
});
|
||||
|
||||
it("finds the raw Matrix account entry by normalized account id", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
"Team Ops": {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
userId: "@ops:example.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
};
|
||||
|
||||
expect(findMatrixAccountEntry(cfg, "team-ops")).toEqual({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("discovers env-backed named Matrix accounts during enumeration", () => {
|
||||
const keys = getMatrixScopedEnvVarNames("team-ops");
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
};
|
||||
const env = {
|
||||
[keys.homeserver]: "https://matrix.example.org",
|
||||
[keys.accessToken]: "secret",
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["team-ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("team-ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats mixed default and named env-backed Matrix accounts as multi-account", () => {
|
||||
const keys = getMatrixScopedEnvVarNames("team-ops");
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
};
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
} as NodeJS.ProcessEnv;
|
||||
MATRIX_ACCESS_TOKEN: "default-secret",
|
||||
[keys.homeserver]: "https://matrix.example.org",
|
||||
[keys.accessToken]: "team-secret",
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default", "ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default");
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default", "team-ops"]);
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not materialize a top-level default account from partial shared auth fields", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accessToken: "shared-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, {} as NodeJS.ProcessEnv)).toEqual(["ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, {} as NodeJS.ProcessEnv)).toBe("ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, {} as NodeJS.ProcessEnv)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not materialize a default env account from partial global auth fields", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_ACCESS_TOKEN: "shared-token",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not materialize a top-level default account from homeserver plus userId alone", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@default:example.org",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, {} as NodeJS.ProcessEnv)).toEqual(["ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, {} as NodeJS.ProcessEnv)).toBe("ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, {} as NodeJS.ProcessEnv)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not materialize a default env account from global homeserver plus userId alone", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as CoreConfig;
|
||||
it("discovers default Matrix accounts backed only by global env vars", () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_USER_ID: "@default:example.org",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
MATRIX_ACCESS_TOKEN: "default-secret",
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
|
||||
});
|
||||
|
||||
it("counts env-backed named accounts when shared homeserver comes from channel config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps env-backed named accounts that rely on cached credentials", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_USER_ID: "@ops:example.org",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
194
extensions/matrix/src/legacy-crypto.test.ts
Normal file
194
extensions/matrix/src/legacy-crypto.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../../test/helpers/temp-home.js";
|
||||
import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./legacy-crypto.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "./storage-paths.js";
|
||||
import {
|
||||
MATRIX_DEFAULT_ACCESS_TOKEN,
|
||||
MATRIX_DEFAULT_DEVICE_ID,
|
||||
MATRIX_DEFAULT_USER_ID,
|
||||
MATRIX_OPS_ACCESS_TOKEN,
|
||||
MATRIX_OPS_ACCOUNT_ID,
|
||||
MATRIX_OPS_DEVICE_ID,
|
||||
MATRIX_OPS_USER_ID,
|
||||
MATRIX_TEST_HOMESERVER,
|
||||
writeFile,
|
||||
writeMatrixCredentials,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
function createDefaultMatrixConfig(): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: MATRIX_DEFAULT_USER_ID,
|
||||
accessToken: MATRIX_DEFAULT_ACCESS_TOKEN,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeDefaultLegacyCryptoFixture(home: string) {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const cfg = createDefaultMatrixConfig();
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: MATRIX_DEFAULT_USER_ID,
|
||||
accessToken: MATRIX_DEFAULT_ACCESS_TOKEN,
|
||||
});
|
||||
writeFile(
|
||||
path.join(rootDir, "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: MATRIX_DEFAULT_DEVICE_ID }),
|
||||
);
|
||||
return { cfg, rootDir };
|
||||
}
|
||||
|
||||
function createOpsLegacyCryptoFixture(params: {
|
||||
home: string;
|
||||
accessToken?: string;
|
||||
includeStoredCredentials?: boolean;
|
||||
}) {
|
||||
const stateDir = path.join(params.home, ".openclaw");
|
||||
writeFile(
|
||||
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: MATRIX_OPS_DEVICE_ID }),
|
||||
);
|
||||
if (params.includeStoredCredentials) {
|
||||
writeMatrixCredentials(stateDir, {
|
||||
accountId: MATRIX_OPS_ACCOUNT_ID,
|
||||
accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
|
||||
deviceId: MATRIX_OPS_DEVICE_ID,
|
||||
});
|
||||
}
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: MATRIX_OPS_USER_ID,
|
||||
accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
|
||||
accountId: MATRIX_OPS_ACCOUNT_ID,
|
||||
});
|
||||
return { rootDir };
|
||||
}
|
||||
|
||||
describe("matrix legacy encrypted-state migration", () => {
|
||||
afterEach(() => {});
|
||||
|
||||
it("extracts a saved backup key into the new recovery-key path", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
|
||||
|
||||
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
|
||||
expect(detection.warnings).toEqual([]);
|
||||
expect(detection.plans).toHaveLength(1);
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
deps: {
|
||||
inspectLegacyStore: async () => ({
|
||||
deviceId: MATRIX_DEFAULT_DEVICE_ID,
|
||||
roomKeyCounts: { total: 12, backedUp: 12 },
|
||||
backupVersion: "1",
|
||||
decryptionKeyBase64: "YWJjZA==",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toEqual([]);
|
||||
|
||||
const recovery = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"),
|
||||
) as {
|
||||
privateKeyBase64: string;
|
||||
};
|
||||
expect(recovery.privateKeyBase64).toBe("YWJjZA==");
|
||||
});
|
||||
});
|
||||
|
||||
it("skips migration when no legacy Matrix plans exist", async () => {
|
||||
await withTempHome(async () => {
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg: createDefaultMatrixConfig(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
migrated: false,
|
||||
changes: [],
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when legacy local-only room keys cannot be recovered automatically", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
deps: {
|
||||
inspectLegacyStore: async () => ({
|
||||
deviceId: MATRIX_DEFAULT_DEVICE_ID,
|
||||
roomKeyCounts: { total: 15, backedUp: 10 },
|
||||
backupVersion: null,
|
||||
decryptionKeyBase64: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toContain(
|
||||
'Legacy Matrix encrypted state for account "default" contains 5 room key(s) that were never backed up. Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.',
|
||||
);
|
||||
expect(result.warnings).toContain(
|
||||
'Legacy Matrix encrypted state for account "default" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.',
|
||||
);
|
||||
const state = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
|
||||
) as { restoreStatus: string };
|
||||
expect(state.restoreStatus).toBe("manual-action-required");
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers stored credentials for named accounts when config is token-only", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { rootDir } = createOpsLegacyCryptoFixture({
|
||||
home,
|
||||
includeStoredCredentials: true,
|
||||
});
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
accessToken: MATRIX_OPS_ACCESS_TOKEN,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
deps: {
|
||||
inspectLegacyStore: async () => ({
|
||||
deviceId: MATRIX_OPS_DEVICE_ID,
|
||||
roomKeyCounts: { total: 1, backedUp: 1 },
|
||||
backupVersion: "1",
|
||||
decryptionKeyBase64: "b3Bz",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(fs.existsSync(path.join(rootDir, "recovery-key.json"))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
86
extensions/matrix/src/legacy-state.test.ts
Normal file
86
extensions/matrix/src/legacy-state.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../../test/helpers/temp-home.js";
|
||||
import { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./legacy-state.js";
|
||||
|
||||
function writeFile(filePath: string, value: string) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, value, "utf-8");
|
||||
}
|
||||
|
||||
describe("matrix legacy state migration", () => {
|
||||
it("migrates the flat legacy Matrix store into account-scoped storage", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
|
||||
writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const detection = detectLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(detection && "warning" in detection).toBe(false);
|
||||
if (!detection || "warning" in detection) {
|
||||
throw new Error("expected a migratable Matrix legacy state plan");
|
||||
}
|
||||
|
||||
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(fs.existsSync(path.join(stateDir, "matrix", "bot-storage.json"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(stateDir, "matrix", "crypto"))).toBe(false);
|
||||
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
|
||||
expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses cached Matrix credentials when the config no longer stores an access token", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
|
||||
writeFile(
|
||||
path.join(stateDir, "credentials", "matrix", "credentials.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-from-cache",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const detection = detectLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(detection && "warning" in detection).toBe(false);
|
||||
if (!detection || "warning" in detection) {
|
||||
throw new Error("expected cached credentials to make Matrix migration resolvable");
|
||||
}
|
||||
|
||||
expect(detection.targetRootDir).toContain("matrix.example.org__bot_example.org");
|
||||
|
||||
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
228
extensions/matrix/src/migration-config.test.ts
Normal file
228
extensions/matrix/src/migration-config.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../../test/helpers/temp-home.js";
|
||||
import { resolveMatrixMigrationAccountTarget } from "./migration-config.js";
|
||||
import {
|
||||
MATRIX_OPS_ACCESS_TOKEN,
|
||||
MATRIX_OPS_ACCOUNT_ID,
|
||||
MATRIX_OPS_USER_ID,
|
||||
MATRIX_TEST_HOMESERVER,
|
||||
writeMatrixCredentials,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
function resolveOpsTarget(cfg: OpenClawConfig, env = process.env) {
|
||||
return resolveMatrixMigrationAccountTarget({
|
||||
cfg,
|
||||
env,
|
||||
accountId: MATRIX_OPS_ACCOUNT_ID,
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveMatrixMigrationAccountTarget", () => {
|
||||
it("reuses stored user identity for token-only configs when the access token matches", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeMatrixCredentials(stateDir, {
|
||||
accountId: MATRIX_OPS_ACCOUNT_ID,
|
||||
deviceId: "DEVICE-OPS",
|
||||
accessToken: MATRIX_OPS_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
accessToken: MATRIX_OPS_ACCESS_TOKEN,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const target = resolveOpsTarget(cfg);
|
||||
|
||||
expect(target).not.toBeNull();
|
||||
expect(target?.userId).toBe(MATRIX_OPS_USER_ID);
|
||||
expect(target?.storedDeviceId).toBe("DEVICE-OPS");
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores stored device IDs from stale cached Matrix credentials", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeMatrixCredentials(stateDir, {
|
||||
accountId: MATRIX_OPS_ACCOUNT_ID,
|
||||
userId: "@old-bot:example.org",
|
||||
accessToken: "tok-old",
|
||||
deviceId: "DEVICE-OLD",
|
||||
});
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: "@new-bot:example.org",
|
||||
accessToken: "tok-new",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const target = resolveOpsTarget(cfg);
|
||||
|
||||
expect(target).not.toBeNull();
|
||||
expect(target?.userId).toBe("@new-bot:example.org");
|
||||
expect(target?.accessToken).toBe("tok-new");
|
||||
expect(target?.storedDeviceId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not trust stale stored creds on the same homeserver when the token changes", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeMatrixCredentials(stateDir, {
|
||||
accountId: MATRIX_OPS_ACCOUNT_ID,
|
||||
userId: "@old-bot:example.org",
|
||||
accessToken: "tok-old",
|
||||
deviceId: "DEVICE-OLD",
|
||||
});
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
accessToken: "tok-new",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const target = resolveOpsTarget(cfg);
|
||||
|
||||
expect(target).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not inherit the base userId for non-default token-only accounts", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeMatrixCredentials(stateDir, {
|
||||
accountId: MATRIX_OPS_ACCOUNT_ID,
|
||||
deviceId: "DEVICE-OPS",
|
||||
accessToken: MATRIX_OPS_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: "@base-bot:example.org",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
accessToken: MATRIX_OPS_ACCESS_TOKEN,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const target = resolveOpsTarget(cfg);
|
||||
|
||||
expect(target).not.toBeNull();
|
||||
expect(target?.userId).toBe(MATRIX_OPS_USER_ID);
|
||||
expect(target?.storedDeviceId).toBe("DEVICE-OPS");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not inherit the base access token for non-default accounts", async () => {
|
||||
await withTempHome(async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: "@base-bot:example.org",
|
||||
accessToken: "tok-base",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: MATRIX_OPS_USER_ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const target = resolveOpsTarget(cfg);
|
||||
|
||||
expect(target).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not inherit the global Matrix access token for non-default accounts", async () => {
|
||||
await withTempHome(
|
||||
async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: MATRIX_OPS_USER_ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const target = resolveOpsTarget(cfg);
|
||||
|
||||
expect(target).toBeNull();
|
||||
},
|
||||
{
|
||||
env: {
|
||||
MATRIX_ACCESS_TOKEN: "tok-global",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the same scoped env token encoding as runtime account auth", async () => {
|
||||
await withTempHome(async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
"ops-prod": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const env = {
|
||||
MATRIX_OPS_X2D_PROD_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_X2D_PROD_USER_ID: "@ops-prod:example.org",
|
||||
MATRIX_OPS_X2D_PROD_ACCESS_TOKEN: "tok-ops-prod",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const target = resolveMatrixMigrationAccountTarget({
|
||||
cfg,
|
||||
env,
|
||||
accountId: "ops-prod",
|
||||
});
|
||||
|
||||
expect(target).not.toBeNull();
|
||||
expect(target?.homeserver).toBe("https://matrix.example.org");
|
||||
expect(target?.userId).toBe("@ops-prod:example.org");
|
||||
expect(target?.accessToken).toBe("tok-ops-prod");
|
||||
});
|
||||
});
|
||||
});
|
||||
98
extensions/matrix/src/migration-snapshot.test.ts
Normal file
98
extensions/matrix/src/migration-snapshot.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../../test/helpers/temp-home.js";
|
||||
import { detectLegacyMatrixCrypto } from "./legacy-crypto.js";
|
||||
import {
|
||||
hasActionableMatrixMigration,
|
||||
maybeCreateMatrixMigrationSnapshot,
|
||||
resolveMatrixMigrationSnapshotMarkerPath,
|
||||
resolveMatrixMigrationSnapshotOutputDir,
|
||||
} from "./migration-snapshot.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "./storage-paths.js";
|
||||
|
||||
describe("matrix migration snapshots", () => {
|
||||
it("creates a backup marker after writing a pre-migration snapshot", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
fs.writeFileSync(path.join(home, ".openclaw", "openclaw.json"), "{}\n", "utf8");
|
||||
fs.writeFileSync(path.join(home, ".openclaw", "state.txt"), "state\n", "utf8");
|
||||
|
||||
const result = await maybeCreateMatrixMigrationSnapshot({ trigger: "unit-test" });
|
||||
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.markerPath).toBe(resolveMatrixMigrationSnapshotMarkerPath(process.env));
|
||||
expect(
|
||||
result.archivePath.startsWith(resolveMatrixMigrationSnapshotOutputDir(process.env)),
|
||||
).toBe(true);
|
||||
expect(fs.existsSync(result.archivePath)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("treats resolvable Matrix legacy state as actionable", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
fs.mkdirSync(path.join(stateDir, "matrix"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(stateDir, "matrix", "bot-storage.json"),
|
||||
'{"legacy":true}',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(
|
||||
hasActionableMatrixMigration({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
env: process.env,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("treats legacy Matrix crypto as actionable when the extension inspector is present", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
});
|
||||
fs.mkdirSync(path.join(rootDir, "crypto"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: "DEVICE123" }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
|
||||
const detection = detectLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
});
|
||||
expect(detection.plans).toHaveLength(1);
|
||||
expect(detection.warnings).toEqual([]);
|
||||
expect(
|
||||
hasActionableMatrixMigration({
|
||||
cfg,
|
||||
env: process.env,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
export {
|
||||
__testing,
|
||||
createTestRegistry,
|
||||
registerSessionBindingAdapter,
|
||||
resolveAgentRoute,
|
||||
setActivePluginRegistry,
|
||||
type OpenClawConfig,
|
||||
} from "../../../../test/helpers/plugins/matrix-monitor-route.js";
|
||||
__testing,
|
||||
} from "../../../../src/infra/outbound/session-binding-service.js";
|
||||
export { setActivePluginRegistry } from "../../../../src/plugins/runtime.js";
|
||||
export { resolveAgentRoute } from "../../../../src/routing/resolve-route.js";
|
||||
export { createTestRegistry } from "../../../../src/test-utils/channel-plugins.js";
|
||||
export type { OpenClawConfig } from "../../../../src/config/config.js";
|
||||
|
||||
193
extensions/signal/src/install-signal-cli.test.ts
Normal file
193
extensions/signal/src/install-signal-cli.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import JSZip from "jszip";
|
||||
import * as tar from "tar";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ReleaseAsset } from "./install-signal-cli.js";
|
||||
import { extractSignalCliArchive, looksLikeArchive, pickAsset } from "./install-signal-cli.js";
|
||||
|
||||
const SAMPLE_ASSETS: ReleaseAsset[] = [
|
||||
{
|
||||
name: "signal-cli-0.13.14-Linux-native.tar.gz",
|
||||
browser_download_url: "https://example.com/linux-native.tar.gz",
|
||||
},
|
||||
{
|
||||
name: "signal-cli-0.13.14-Linux-native.tar.gz.asc",
|
||||
browser_download_url: "https://example.com/linux-native.tar.gz.asc",
|
||||
},
|
||||
{
|
||||
name: "signal-cli-0.13.14-macOS-native.tar.gz",
|
||||
browser_download_url: "https://example.com/macos-native.tar.gz",
|
||||
},
|
||||
{
|
||||
name: "signal-cli-0.13.14-macOS-native.tar.gz.asc",
|
||||
browser_download_url: "https://example.com/macos-native.tar.gz.asc",
|
||||
},
|
||||
{
|
||||
name: "signal-cli-0.13.14-Windows-native.zip",
|
||||
browser_download_url: "https://example.com/windows-native.zip",
|
||||
},
|
||||
{
|
||||
name: "signal-cli-0.13.14-Windows-native.zip.asc",
|
||||
browser_download_url: "https://example.com/windows-native.zip.asc",
|
||||
},
|
||||
{ name: "signal-cli-0.13.14.tar.gz", browser_download_url: "https://example.com/jvm.tar.gz" },
|
||||
{
|
||||
name: "signal-cli-0.13.14.tar.gz.asc",
|
||||
browser_download_url: "https://example.com/jvm.tar.gz.asc",
|
||||
},
|
||||
];
|
||||
|
||||
describe("looksLikeArchive", () => {
|
||||
it("recognises .tar.gz", () => {
|
||||
expect(looksLikeArchive("foo.tar.gz")).toBe(true);
|
||||
});
|
||||
|
||||
it("recognises .tgz", () => {
|
||||
expect(looksLikeArchive("foo.tgz")).toBe(true);
|
||||
});
|
||||
|
||||
it("recognises .zip", () => {
|
||||
expect(looksLikeArchive("foo.zip")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects signature files", () => {
|
||||
expect(looksLikeArchive("foo.tar.gz.asc")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unrelated files", () => {
|
||||
expect(looksLikeArchive("README.md")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickAsset", () => {
|
||||
describe("linux", () => {
|
||||
it("selects the Linux-native asset on x64", () => {
|
||||
const result = pickAsset(SAMPLE_ASSETS, "linux", "x64");
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.name).toContain("Linux-native");
|
||||
expect(result!.name).toMatch(/\.tar\.gz$/);
|
||||
});
|
||||
|
||||
it("returns undefined on arm64 (triggers brew fallback)", () => {
|
||||
const result = pickAsset(SAMPLE_ASSETS, "linux", "arm64");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined on arm (32-bit)", () => {
|
||||
const result = pickAsset(SAMPLE_ASSETS, "linux", "arm");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("darwin", () => {
|
||||
it("selects the macOS-native asset", () => {
|
||||
const result = pickAsset(SAMPLE_ASSETS, "darwin", "arm64");
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.name).toContain("macOS-native");
|
||||
});
|
||||
|
||||
it("selects the macOS-native asset on x64", () => {
|
||||
const result = pickAsset(SAMPLE_ASSETS, "darwin", "x64");
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.name).toContain("macOS-native");
|
||||
});
|
||||
});
|
||||
|
||||
describe("win32", () => {
|
||||
it("selects the Windows-native asset", () => {
|
||||
const result = pickAsset(SAMPLE_ASSETS, "win32", "x64");
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.name).toContain("Windows-native");
|
||||
expect(result!.name).toMatch(/\.zip$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("returns undefined for an empty asset list", () => {
|
||||
expect(pickAsset([], "linux", "x64")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips assets with missing name or url", () => {
|
||||
const partial: ReleaseAsset[] = [
|
||||
{ name: "signal-cli.tar.gz" },
|
||||
{ browser_download_url: "https://example.com/file.tar.gz" },
|
||||
];
|
||||
expect(pickAsset(partial, "linux", "x64")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to first archive for unknown platform", () => {
|
||||
const result = pickAsset(SAMPLE_ASSETS, "freebsd" as NodeJS.Platform, "x64");
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.name).toMatch(/\.tar\.gz$/);
|
||||
});
|
||||
|
||||
it("never selects .asc signature files", () => {
|
||||
const result = pickAsset(SAMPLE_ASSETS, "linux", "x64");
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.name).not.toMatch(/\.asc$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSignalCliArchive", () => {
|
||||
async function withArchiveWorkspace(run: (workDir: string) => Promise<void>) {
|
||||
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-install-"));
|
||||
try {
|
||||
await run(workDir);
|
||||
} finally {
|
||||
await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
it("rejects zip slip path traversal", async () => {
|
||||
await withArchiveWorkspace(async (workDir) => {
|
||||
const archivePath = path.join(workDir, "bad.zip");
|
||||
const extractDir = path.join(workDir, "extract");
|
||||
await fs.mkdir(extractDir, { recursive: true });
|
||||
|
||||
const zip = new JSZip();
|
||||
zip.file("../pwned.txt", "pwnd");
|
||||
await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" }));
|
||||
|
||||
await expect(extractSignalCliArchive(archivePath, extractDir, 5_000)).rejects.toThrow(
|
||||
/(escapes destination|absolute)/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts zip archives", async () => {
|
||||
await withArchiveWorkspace(async (workDir) => {
|
||||
const archivePath = path.join(workDir, "ok.zip");
|
||||
const extractDir = path.join(workDir, "extract");
|
||||
await fs.mkdir(extractDir, { recursive: true });
|
||||
|
||||
const zip = new JSZip();
|
||||
zip.file("root/signal-cli", "bin");
|
||||
await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" }));
|
||||
|
||||
await extractSignalCliArchive(archivePath, extractDir, 5_000);
|
||||
|
||||
const extracted = await fs.readFile(path.join(extractDir, "root", "signal-cli"), "utf-8");
|
||||
expect(extracted).toBe("bin");
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts tar.gz archives", async () => {
|
||||
await withArchiveWorkspace(async (workDir) => {
|
||||
const archivePath = path.join(workDir, "ok.tgz");
|
||||
const extractDir = path.join(workDir, "extract");
|
||||
const rootDir = path.join(workDir, "root");
|
||||
await fs.mkdir(rootDir, { recursive: true });
|
||||
await fs.writeFile(path.join(rootDir, "signal-cli"), "bin", "utf-8");
|
||||
await tar.c({ cwd: workDir, file: archivePath, gzip: true }, ["root"]);
|
||||
|
||||
await fs.mkdir(extractDir, { recursive: true });
|
||||
await extractSignalCliArchive(archivePath, extractDir, 5_000);
|
||||
|
||||
const extracted = await fs.readFile(path.join(extractDir, "root", "signal-cli"), "utf-8");
|
||||
expect(extracted).toBe("bin");
|
||||
});
|
||||
});
|
||||
});
|
||||
50
extensions/signal/src/normalize.test.ts
Normal file
50
extensions/signal/src/normalize.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize.js";
|
||||
|
||||
describe("normalizeSignalMessagingTarget", () => {
|
||||
it("normalizes uuid targets by stripping uuid:", () => {
|
||||
expect(normalizeSignalMessagingTarget("uuid:123E4567-E89B-12D3-A456-426614174000")).toBe(
|
||||
"123e4567-e89b-12d3-a456-426614174000",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes signal:uuid targets", () => {
|
||||
expect(normalizeSignalMessagingTarget("signal:uuid:123E4567-E89B-12D3-A456-426614174000")).toBe(
|
||||
"123e4567-e89b-12d3-a456-426614174000",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves case for group targets", () => {
|
||||
expect(
|
||||
normalizeSignalMessagingTarget("signal:group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="),
|
||||
).toBe("group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=");
|
||||
});
|
||||
|
||||
it("preserves case for base64-like group IDs without signal prefix", () => {
|
||||
expect(
|
||||
normalizeSignalMessagingTarget("group:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789+/ABCD="),
|
||||
).toBe("group:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789+/ABCD=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeSignalTargetId", () => {
|
||||
it("accepts uuid prefixes for target detection", () => {
|
||||
expect(looksLikeSignalTargetId("uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true);
|
||||
expect(looksLikeSignalTargetId("signal:uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts signal-prefixed E.164 targets for detection", () => {
|
||||
expect(looksLikeSignalTargetId("signal:+15551234567")).toBe(true);
|
||||
expect(looksLikeSignalTargetId("signal:15551234567")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts compact UUIDs for target detection", () => {
|
||||
expect(looksLikeSignalTargetId("123e4567e89b12d3a456426614174000")).toBe(true);
|
||||
expect(looksLikeSignalTargetId("uuid:123e4567e89b12d3a456426614174000")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid uuid prefixes", () => {
|
||||
expect(looksLikeSignalTargetId("uuid:")).toBe(false);
|
||||
expect(looksLikeSignalTargetId("uuid:not-a-uuid")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createSlackOutboundPayloadHarness } from "../../../src/channels/plugins/contracts/slack-outbound-harness.js";
|
||||
import { createSlackOutboundPayloadHarness } from "../contract-api.js";
|
||||
|
||||
function createHarness(params: {
|
||||
payload: ReplyPayload;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { TELEGRAM_COMMAND_NAME_PATTERN } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { STATE_DIR } from "openclaw/plugin-sdk/state-paths";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TELEGRAM_COMMAND_NAME_PATTERN } from "./command-config.js";
|
||||
import { pluginCommandMocks, resetPluginCommandMocks } from "./test-support/plugin-command.js";
|
||||
|
||||
let registerTelegramNativeCommands: typeof import("./bot-native-commands.js").registerTelegramNativeCommands;
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createWhatsAppPollFixture,
|
||||
expectWhatsAppPollSent,
|
||||
} from "../../../src/test-helpers/whatsapp-outbound.js";
|
||||
import { createWhatsAppPollFixture, expectWhatsAppPollSent } from "../contract-api.js";
|
||||
import { createWhatsAppOutboundBase } from "./outbound-base.js";
|
||||
|
||||
describe("createWhatsAppOutboundBase", () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isWhatsAppGroupJid,
|
||||
looksLikeWhatsAppTargetId,
|
||||
isWhatsAppUserTarget,
|
||||
normalizeWhatsAppMessagingTarget,
|
||||
normalizeWhatsAppTarget,
|
||||
} from "./normalize-target.js";
|
||||
|
||||
@@ -71,3 +73,18 @@ describe("isWhatsAppGroupJid", () => {
|
||||
expect(isWhatsAppGroupJid("+1555123")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeWhatsAppMessagingTarget", () => {
|
||||
it("normalizes blank inputs to undefined", () => {
|
||||
expect(normalizeWhatsAppMessagingTarget(" ")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeWhatsAppTargetId", () => {
|
||||
it("detects common WhatsApp target forms", () => {
|
||||
expect(looksLikeWhatsAppTargetId("whatsapp:+15555550123")).toBe(true);
|
||||
expect(looksLikeWhatsAppTargetId("15555550123@c.us")).toBe(true);
|
||||
expect(looksLikeWhatsAppTargetId("+15555550123")).toBe(true);
|
||||
expect(looksLikeWhatsAppTargetId("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
152
extensions/whatsapp/src/text-runtime.test.ts
Normal file
152
extensions/whatsapp/src/text-runtime.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
assertWebChannel,
|
||||
jidToE164,
|
||||
markdownToWhatsApp,
|
||||
resolveJidToE164,
|
||||
toWhatsappJid,
|
||||
} from "./text-runtime.js";
|
||||
|
||||
const CONFIG_DIR = path.join(process.env.HOME ?? os.tmpdir(), ".openclaw");
|
||||
|
||||
async function withTempDir<T>(
|
||||
prefix: string,
|
||||
run: (dir: string) => T | Promise<T>,
|
||||
): Promise<Awaited<T>> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
try {
|
||||
return await run(dir);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("markdownToWhatsApp", () => {
|
||||
it.each([
|
||||
["converts **bold** to *bold*", "**SOD Blast:**", "*SOD Blast:*"],
|
||||
["converts __bold__ to *bold*", "__important__", "*important*"],
|
||||
["converts ~~strikethrough~~ to ~strikethrough~", "~~deleted~~", "~deleted~"],
|
||||
["leaves single *italic* unchanged (already WhatsApp bold)", "*text*", "*text*"],
|
||||
["leaves _italic_ unchanged (already WhatsApp italic)", "_text_", "_text_"],
|
||||
["preserves inline code", "Use `**not bold**` here", "Use `**not bold**` here"],
|
||||
[
|
||||
"handles mixed formatting",
|
||||
"**bold** and ~~strike~~ and _italic_",
|
||||
"*bold* and ~strike~ and _italic_",
|
||||
],
|
||||
["handles multiple bold segments", "**one** then **two**", "*one* then *two*"],
|
||||
["returns empty string for empty input", "", ""],
|
||||
["returns plain text unchanged", "no formatting here", "no formatting here"],
|
||||
["handles bold inside a sentence", "This is **very** important", "This is *very* important"],
|
||||
] as const)("handles markdown-to-whatsapp conversion: %s", (_name, input, expected) => {
|
||||
expect(markdownToWhatsApp(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("preserves fenced code blocks", () => {
|
||||
const input = "```\nconst x = **bold**;\n```";
|
||||
expect(markdownToWhatsApp(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("preserves code block with formatting inside", () => {
|
||||
const input = "Before ```**bold** and ~~strike~~``` after **real bold**";
|
||||
expect(markdownToWhatsApp(input)).toBe(
|
||||
"Before ```**bold** and ~~strike~~``` after *real bold*",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertWebChannel", () => {
|
||||
it("accepts valid channel", () => {
|
||||
expect(() => assertWebChannel("web")).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws for invalid channel", () => {
|
||||
expect(() => assertWebChannel("bad" as string)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toWhatsappJid", () => {
|
||||
it("strips formatting and prefixes", () => {
|
||||
expect(toWhatsappJid("whatsapp:+555 123 4567")).toBe("5551234567@s.whatsapp.net");
|
||||
});
|
||||
|
||||
it("preserves existing JIDs", () => {
|
||||
expect(toWhatsappJid("123456789-987654321@g.us")).toBe("123456789-987654321@g.us");
|
||||
expect(toWhatsappJid("whatsapp:123456789-987654321@g.us")).toBe("123456789-987654321@g.us");
|
||||
expect(toWhatsappJid("1555123@s.whatsapp.net")).toBe("1555123@s.whatsapp.net");
|
||||
});
|
||||
});
|
||||
|
||||
describe("jidToE164", () => {
|
||||
it("maps @lid using reverse mapping file", () => {
|
||||
const mappingPath = path.join(CONFIG_DIR, "credentials", "lid-mapping-123_reverse.json");
|
||||
const original = fs.readFileSync;
|
||||
const spy = vi.spyOn(fs, "readFileSync").mockImplementation((...args) => {
|
||||
if (args[0] === mappingPath) {
|
||||
return `"5551234"`;
|
||||
}
|
||||
return original(...args);
|
||||
});
|
||||
expect(jidToE164("123@lid")).toBe("+5551234");
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("maps @lid from authDir mapping files", async () => {
|
||||
await withTempDir("openclaw-auth-", (authDir) => {
|
||||
const mappingPath = path.join(authDir, "lid-mapping-456_reverse.json");
|
||||
fs.writeFileSync(mappingPath, JSON.stringify("5559876"));
|
||||
expect(jidToE164("456@lid", { authDir })).toBe("+5559876");
|
||||
});
|
||||
});
|
||||
|
||||
it("maps @hosted.lid from authDir mapping files", async () => {
|
||||
await withTempDir("openclaw-auth-", (authDir) => {
|
||||
const mappingPath = path.join(authDir, "lid-mapping-789_reverse.json");
|
||||
fs.writeFileSync(mappingPath, JSON.stringify(4440001));
|
||||
expect(jidToE164("789@hosted.lid", { authDir })).toBe("+4440001");
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts hosted PN JIDs", () => {
|
||||
expect(jidToE164("1555000:2@hosted")).toBe("+1555000");
|
||||
});
|
||||
|
||||
it("falls back through lidMappingDirs in order", async () => {
|
||||
await withTempDir("openclaw-lid-a-", async (first) => {
|
||||
await withTempDir("openclaw-lid-b-", (second) => {
|
||||
const mappingPath = path.join(second, "lid-mapping-321_reverse.json");
|
||||
fs.writeFileSync(mappingPath, JSON.stringify("123321"));
|
||||
expect(jidToE164("321@lid", { lidMappingDirs: [first, second] })).toBe("+123321");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveJidToE164", () => {
|
||||
it("resolves @lid via lidLookup when mapping file is missing", async () => {
|
||||
const lidLookup = {
|
||||
getPNForLID: vi.fn().mockResolvedValue("777:0@s.whatsapp.net"),
|
||||
};
|
||||
await expect(resolveJidToE164("777@lid", { lidLookup })).resolves.toBe("+777");
|
||||
expect(lidLookup.getPNForLID).toHaveBeenCalledWith("777@lid");
|
||||
});
|
||||
|
||||
it("skips lidLookup for non-lid JIDs", async () => {
|
||||
const lidLookup = {
|
||||
getPNForLID: vi.fn().mockResolvedValue("888:0@s.whatsapp.net"),
|
||||
};
|
||||
await expect(resolveJidToE164("888@s.whatsapp.net", { lidLookup })).resolves.toBe("+888");
|
||||
expect(lidLookup.getPNForLID).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when lidLookup throws", async () => {
|
||||
const lidLookup = {
|
||||
getPNForLID: vi.fn().mockRejectedValue(new Error("lookup failed")),
|
||||
};
|
||||
await expect(resolveJidToE164("777@lid", { lidLookup })).resolves.toBeNull();
|
||||
expect(lidLookup.getPNForLID).toHaveBeenCalledWith("777@lid");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { request as httpRequest } from "node:http";
|
||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/zalo";
|
||||
import { expect, vi } from "vitest";
|
||||
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
|
||||
import type { ResolvedZaloAccount } from "../src/types.js";
|
||||
|
||||
export function createLifecycleConfig(params: {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/zalo";
|
||||
import { vi, type Mock } from "vitest";
|
||||
import {
|
||||
createEmptyPluginRegistry,
|
||||
@@ -6,6 +5,7 @@ import {
|
||||
} from "../../../test/helpers/plugins/plugin-registry.js";
|
||||
import { createPluginRuntimeMock } from "../../../test/helpers/plugins/plugin-runtime-mock.js";
|
||||
import { createRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js";
|
||||
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
|
||||
import type { ResolvedZaloAccount } from "../src/types.js";
|
||||
|
||||
type MonitorModule = typeof import("../src/monitor.js");
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { parseTelegramTopicConversation } from "../../extensions/telegram/api.js";
|
||||
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||
import type { ChannelConfiguredBindingProvider, ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { parseTelegramTopicConversation } from "./conversation-id.js";
|
||||
import { buildConfiguredAcpSessionKey } from "./persistent-bindings.types.js";
|
||||
const managerMocks = vi.hoisted(() => ({
|
||||
resolveSession: vi.fn(),
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
resolveAnthropicBetas,
|
||||
resolveAnthropicFastMode,
|
||||
resolveAnthropicServiceTier,
|
||||
} from "../../extensions/anthropic/api.js";
|
||||
} from "../../test/helpers/providers/anthropic-contract.js";
|
||||
import { createConfiguredOllamaCompatNumCtxWrapper } from "../plugin-sdk/ollama.js";
|
||||
import { __testing as extraParamsTesting } from "./pi-embedded-runner/extra-params.js";
|
||||
import {
|
||||
|
||||
@@ -2,8 +2,10 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildTelegramModelsProviderChannelData } from "../../../extensions/telegram/api.js";
|
||||
import { whatsappCommandPolicy } from "../../../extensions/whatsapp/api.js";
|
||||
import {
|
||||
buildTelegramModelsProviderChannelData,
|
||||
whatsappCommandPolicy,
|
||||
} from "../../../test/helpers/channels/command-contract.js";
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { updateSessionStore, type SessionEntry } from "../../config/sessions.js";
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
import {
|
||||
createAgendaCard,
|
||||
createAppleTvRemoteCard,
|
||||
createDeviceControlCard,
|
||||
createMediaPlayerCard,
|
||||
createEventCard,
|
||||
} from "../../plugin-sdk/line.js";
|
||||
import type { LineChannelData } from "../../plugin-sdk/line.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
|
||||
/**
|
||||
* Parse LINE-specific directives from text and extract them into ReplyPayload fields.
|
||||
*
|
||||
* Supported directives:
|
||||
* - [[quick_replies: option1, option2, option3]]
|
||||
* - [[location: title | address | latitude | longitude]]
|
||||
* - [[confirm: question | yes_label | no_label]]
|
||||
* - [[buttons: title | text | btn1:data1, btn2:data2]]
|
||||
* - [[media_player: title | artist | source | imageUrl | playing/paused]]
|
||||
* - [[event: title | date | time | location | description]]
|
||||
* - [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]]
|
||||
* - [[device: name | type | status | ctrl1:data1, ctrl2:data2]]
|
||||
* - [[appletv_remote: name | status]]
|
||||
*
|
||||
* Returns the modified payload with directives removed from text and fields populated.
|
||||
*/
|
||||
export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
let text = payload.text;
|
||||
if (!text) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const result: ReplyPayload = { ...payload };
|
||||
const lineData: LineChannelData = {
|
||||
...(result.channelData?.line as LineChannelData | undefined),
|
||||
};
|
||||
const toSlug = (value: string): string =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "") || "device";
|
||||
const lineActionData = (action: string, extras?: Record<string, string>): string => {
|
||||
const base = [`line.action=${encodeURIComponent(action)}`];
|
||||
if (extras) {
|
||||
for (const [key, value] of Object.entries(extras)) {
|
||||
base.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
||||
}
|
||||
}
|
||||
return base.join("&");
|
||||
};
|
||||
|
||||
// Parse [[quick_replies: option1, option2, option3]]
|
||||
const quickRepliesMatch = text.match(/\[\[quick_replies:\s*([^\]]+)\]\]/i);
|
||||
if (quickRepliesMatch) {
|
||||
const options = quickRepliesMatch[1]
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (options.length > 0) {
|
||||
lineData.quickReplies = [...(lineData.quickReplies || []), ...options];
|
||||
}
|
||||
text = text.replace(quickRepliesMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[location: title | address | latitude | longitude]]
|
||||
const locationMatch = text.match(/\[\[location:\s*([^\]]+)\]\]/i);
|
||||
if (locationMatch && !lineData.location) {
|
||||
const parts = locationMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 4) {
|
||||
const [title, address, latStr, lonStr] = parts;
|
||||
const latitude = parseFloat(latStr);
|
||||
const longitude = parseFloat(lonStr);
|
||||
if (!isNaN(latitude) && !isNaN(longitude)) {
|
||||
lineData.location = {
|
||||
title: title || "Location",
|
||||
address: address || "",
|
||||
latitude,
|
||||
longitude,
|
||||
};
|
||||
}
|
||||
}
|
||||
text = text.replace(locationMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[confirm: question | yes_label | no_label]] or [[confirm: question | yes_label:yes_data | no_label:no_data]]
|
||||
const confirmMatch = text.match(/\[\[confirm:\s*([^\]]+)\]\]/i);
|
||||
if (confirmMatch && !lineData.templateMessage) {
|
||||
const parts = confirmMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 3) {
|
||||
const [question, yesPart, noPart] = parts;
|
||||
|
||||
// Parse yes_label:yes_data format
|
||||
const [yesLabel, yesData] = yesPart.includes(":")
|
||||
? yesPart.split(":").map((s) => s.trim())
|
||||
: [yesPart, yesPart.toLowerCase()];
|
||||
|
||||
const [noLabel, noData] = noPart.includes(":")
|
||||
? noPart.split(":").map((s) => s.trim())
|
||||
: [noPart, noPart.toLowerCase()];
|
||||
|
||||
lineData.templateMessage = {
|
||||
type: "confirm",
|
||||
text: question,
|
||||
confirmLabel: yesLabel,
|
||||
confirmData: yesData,
|
||||
cancelLabel: noLabel,
|
||||
cancelData: noData,
|
||||
altText: question,
|
||||
};
|
||||
}
|
||||
text = text.replace(confirmMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[buttons: title | text | btn1:data1, btn2:data2]]
|
||||
const buttonsMatch = text.match(/\[\[buttons:\s*([^\]]+)\]\]/i);
|
||||
if (buttonsMatch && !lineData.templateMessage) {
|
||||
const parts = buttonsMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 3) {
|
||||
const [title, bodyText, actionsStr] = parts;
|
||||
|
||||
const actions = actionsStr.split(",").map((actionStr) => {
|
||||
const trimmed = actionStr.trim();
|
||||
// Find first colon delimiter, ignoring URLs without a label.
|
||||
const colonIndex = (() => {
|
||||
const index = trimmed.indexOf(":");
|
||||
if (index === -1) {
|
||||
return -1;
|
||||
}
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.startsWith("http://") || lower.startsWith("https://")) {
|
||||
return -1;
|
||||
}
|
||||
return index;
|
||||
})();
|
||||
|
||||
let label: string;
|
||||
let data: string;
|
||||
|
||||
if (colonIndex === -1) {
|
||||
label = trimmed;
|
||||
data = trimmed;
|
||||
} else {
|
||||
label = trimmed.slice(0, colonIndex).trim();
|
||||
data = trimmed.slice(colonIndex + 1).trim();
|
||||
}
|
||||
|
||||
// Detect action type
|
||||
if (data.startsWith("http://") || data.startsWith("https://")) {
|
||||
return { type: "uri" as const, label, uri: data };
|
||||
}
|
||||
if (data.includes("=")) {
|
||||
return { type: "postback" as const, label, data };
|
||||
}
|
||||
return { type: "message" as const, label, data: data || label };
|
||||
});
|
||||
|
||||
if (actions.length > 0) {
|
||||
lineData.templateMessage = {
|
||||
type: "buttons",
|
||||
title,
|
||||
text: bodyText,
|
||||
actions: actions.slice(0, 4), // LINE limit
|
||||
altText: `${title}: ${bodyText}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
text = text.replace(buttonsMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[media_player: title | artist | source | imageUrl | playing/paused]]
|
||||
const mediaPlayerMatch = text.match(/\[\[media_player:\s*([^\]]+)\]\]/i);
|
||||
if (mediaPlayerMatch && !lineData.flexMessage) {
|
||||
const parts = mediaPlayerMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 1) {
|
||||
const [title, artist, source, imageUrl, statusStr] = parts;
|
||||
const isPlaying = statusStr?.toLowerCase() === "playing";
|
||||
|
||||
// LINE requires HTTPS URLs for images - skip local/HTTP URLs
|
||||
const validImageUrl = imageUrl?.startsWith("https://") ? imageUrl : undefined;
|
||||
|
||||
const deviceKey = toSlug(source || title || "media");
|
||||
const card = createMediaPlayerCard({
|
||||
title: title || "Unknown Track",
|
||||
subtitle: artist || undefined,
|
||||
source: source || undefined,
|
||||
imageUrl: validImageUrl,
|
||||
isPlaying: statusStr ? isPlaying : undefined,
|
||||
controls: {
|
||||
previous: { data: lineActionData("previous", { "line.device": deviceKey }) },
|
||||
play: { data: lineActionData("play", { "line.device": deviceKey }) },
|
||||
pause: { data: lineActionData("pause", { "line.device": deviceKey }) },
|
||||
next: { data: lineActionData("next", { "line.device": deviceKey }) },
|
||||
},
|
||||
});
|
||||
|
||||
lineData.flexMessage = {
|
||||
altText: `🎵 ${title}${artist ? ` - ${artist}` : ""}`,
|
||||
contents: card,
|
||||
};
|
||||
}
|
||||
text = text.replace(mediaPlayerMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[event: title | date | time | location | description]]
|
||||
const eventMatch = text.match(/\[\[event:\s*([^\]]+)\]\]/i);
|
||||
if (eventMatch && !lineData.flexMessage) {
|
||||
const parts = eventMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 2) {
|
||||
const [title, date, time, location, description] = parts;
|
||||
|
||||
const card = createEventCard({
|
||||
title: title || "Event",
|
||||
date: date || "TBD",
|
||||
time: time || undefined,
|
||||
location: location || undefined,
|
||||
description: description || undefined,
|
||||
});
|
||||
|
||||
lineData.flexMessage = {
|
||||
altText: `📅 ${title} - ${date}${time ? ` ${time}` : ""}`,
|
||||
contents: card,
|
||||
};
|
||||
}
|
||||
text = text.replace(eventMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[appletv_remote: name | status]]
|
||||
const appleTvMatch = text.match(/\[\[appletv_remote:\s*([^\]]+)\]\]/i);
|
||||
if (appleTvMatch && !lineData.flexMessage) {
|
||||
const parts = appleTvMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 1) {
|
||||
const [deviceName, status] = parts;
|
||||
const deviceKey = toSlug(deviceName || "apple_tv");
|
||||
|
||||
const card = createAppleTvRemoteCard({
|
||||
deviceName: deviceName || "Apple TV",
|
||||
status: status || undefined,
|
||||
actionData: {
|
||||
up: lineActionData("up", { "line.device": deviceKey }),
|
||||
down: lineActionData("down", { "line.device": deviceKey }),
|
||||
left: lineActionData("left", { "line.device": deviceKey }),
|
||||
right: lineActionData("right", { "line.device": deviceKey }),
|
||||
select: lineActionData("select", { "line.device": deviceKey }),
|
||||
menu: lineActionData("menu", { "line.device": deviceKey }),
|
||||
home: lineActionData("home", { "line.device": deviceKey }),
|
||||
play: lineActionData("play", { "line.device": deviceKey }),
|
||||
pause: lineActionData("pause", { "line.device": deviceKey }),
|
||||
volumeUp: lineActionData("volume_up", { "line.device": deviceKey }),
|
||||
volumeDown: lineActionData("volume_down", { "line.device": deviceKey }),
|
||||
mute: lineActionData("mute", { "line.device": deviceKey }),
|
||||
},
|
||||
});
|
||||
|
||||
lineData.flexMessage = {
|
||||
altText: `📺 ${deviceName || "Apple TV"} Remote`,
|
||||
contents: card,
|
||||
};
|
||||
}
|
||||
text = text.replace(appleTvMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]]
|
||||
const agendaMatch = text.match(/\[\[agenda:\s*([^\]]+)\]\]/i);
|
||||
if (agendaMatch && !lineData.flexMessage) {
|
||||
const parts = agendaMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 2) {
|
||||
const [title, eventsStr] = parts;
|
||||
|
||||
const events = eventsStr.split(",").map((eventStr) => {
|
||||
const trimmed = eventStr.trim();
|
||||
const colonIdx = trimmed.lastIndexOf(":");
|
||||
if (colonIdx > 0) {
|
||||
return {
|
||||
title: trimmed.slice(0, colonIdx).trim(),
|
||||
time: trimmed.slice(colonIdx + 1).trim(),
|
||||
};
|
||||
}
|
||||
return { title: trimmed };
|
||||
});
|
||||
|
||||
const card = createAgendaCard({
|
||||
title: title || "Agenda",
|
||||
events,
|
||||
});
|
||||
|
||||
lineData.flexMessage = {
|
||||
altText: `📋 ${title} (${events.length} events)`,
|
||||
contents: card,
|
||||
};
|
||||
}
|
||||
text = text.replace(agendaMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[device: name | type | status | ctrl1:data1, ctrl2:data2]]
|
||||
const deviceMatch = text.match(/\[\[device:\s*([^\]]+)\]\]/i);
|
||||
if (deviceMatch && !lineData.flexMessage) {
|
||||
const parts = deviceMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 1) {
|
||||
const [deviceName, deviceType, status, controlsStr] = parts;
|
||||
|
||||
const deviceKey = toSlug(deviceName || "device");
|
||||
const controls = controlsStr
|
||||
? controlsStr.split(",").map((ctrlStr) => {
|
||||
const [label, data] = ctrlStr.split(":").map((s) => s.trim());
|
||||
const action = data || label.toLowerCase().replace(/\s+/g, "_");
|
||||
return { label, data: lineActionData(action, { "line.device": deviceKey }) };
|
||||
})
|
||||
: [];
|
||||
|
||||
const card = createDeviceControlCard({
|
||||
deviceName: deviceName || "Device",
|
||||
deviceType: deviceType || undefined,
|
||||
status: status || undefined,
|
||||
controls,
|
||||
});
|
||||
|
||||
lineData.flexMessage = {
|
||||
altText: `📱 ${deviceName}${status ? `: ${status}` : ""}`,
|
||||
contents: card,
|
||||
};
|
||||
}
|
||||
text = text.replace(deviceMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Clean up multiple whitespace/newlines
|
||||
text = text.replace(/\n{3,}/g, "\n\n").trim();
|
||||
|
||||
result.text = text || undefined;
|
||||
if (Object.keys(lineData).length > 0) {
|
||||
result.channelData = { ...result.channelData, line: lineData };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text contains any LINE directives
|
||||
*/
|
||||
export function hasLineDirectives(text: string): boolean {
|
||||
return /\[\[(quick_replies|location|confirm|buttons|media_player|event|agenda|device|appletv_remote):/i.test(
|
||||
text,
|
||||
);
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasSlackDirectives, parseSlackDirectives } from "./slack-directives.js";
|
||||
|
||||
const getSlackInteractive = (result: ReturnType<typeof parseSlackDirectives>) =>
|
||||
result.interactive?.blocks ?? [];
|
||||
|
||||
describe("hasSlackDirectives", () => {
|
||||
it("matches expected detection across Slack directive patterns", () => {
|
||||
const cases: Array<{ text: string; expected: boolean }> = [
|
||||
{ text: "Pick one [[slack_buttons: Approve:approve, Reject:reject]]", expected: true },
|
||||
{
|
||||
text: "[[slack_select: Choose a project | Alpha:alpha, Beta:beta]]",
|
||||
expected: true,
|
||||
},
|
||||
{ text: "Just regular text", expected: false },
|
||||
{ text: "[[buttons: Menu | Choose | A:a]]", expected: false },
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
expect(hasSlackDirectives(testCase.text)).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSlackDirectives", () => {
|
||||
it("builds shared text and button blocks from slack_buttons directives", () => {
|
||||
const result = parseSlackDirectives({
|
||||
text: "Choose an action [[slack_buttons: Approve:approve, Reject:reject]]",
|
||||
});
|
||||
|
||||
expect(result.text).toBe("Choose an action");
|
||||
expect(getSlackInteractive(result)).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Choose an action",
|
||||
},
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [
|
||||
{
|
||||
label: "Approve",
|
||||
value: "approve",
|
||||
},
|
||||
{
|
||||
label: "Reject",
|
||||
value: "reject",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds shared select blocks from slack_select directives", () => {
|
||||
const result = parseSlackDirectives({
|
||||
text: "[[slack_select: Choose a project | Alpha:alpha, Beta:beta]]",
|
||||
});
|
||||
|
||||
expect(result.text).toBeUndefined();
|
||||
expect(getSlackInteractive(result)).toEqual([
|
||||
{
|
||||
type: "select",
|
||||
placeholder: "Choose a project",
|
||||
options: [
|
||||
{ label: "Alpha", value: "alpha" },
|
||||
{ label: "Beta", value: "beta" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves existing slack blocks in channelData and appends shared interactive blocks", () => {
|
||||
const result = parseSlackDirectives({
|
||||
text: "Act now [[slack_buttons: Retry:retry]]",
|
||||
channelData: {
|
||||
slack: {
|
||||
blocks: [{ type: "divider" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.text).toBe("Act now");
|
||||
expect(result.channelData).toEqual({
|
||||
slack: {
|
||||
blocks: [{ type: "divider" }],
|
||||
},
|
||||
});
|
||||
expect(getSlackInteractive(result)).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Act now",
|
||||
},
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [{ label: "Retry", value: "retry" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves authored order for mixed Slack directives", () => {
|
||||
const result = parseSlackDirectives({
|
||||
text: "[[slack_select: Pick one | Alpha:alpha]] then [[slack_buttons: Retry:retry]]",
|
||||
});
|
||||
|
||||
expect(getSlackInteractive(result)).toEqual([
|
||||
{
|
||||
type: "select",
|
||||
placeholder: "Pick one",
|
||||
options: [{ label: "Alpha", value: "alpha" }],
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "then",
|
||||
},
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [{ label: "Retry", value: "retry" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves long Slack directive values in the shared interactive model", () => {
|
||||
const long = "x".repeat(120);
|
||||
const result = parseSlackDirectives({
|
||||
text: `${"y".repeat(3100)} [[slack_select: ${long} | ${long}:${long}]] [[slack_buttons: ${long}:${long}]]`,
|
||||
});
|
||||
|
||||
expect(getSlackInteractive(result)).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "y".repeat(3100),
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
placeholder: long,
|
||||
options: [{ label: long, value: long }],
|
||||
},
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [{ label: long, value: long }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses optional Slack button styles without truncating callback values", () => {
|
||||
const result = parseSlackDirectives({
|
||||
text: "[[slack_buttons: Approve:pluginbind:approval-123:o:primary, Reject:deny:danger, Skip:skip:secondary]]",
|
||||
});
|
||||
|
||||
expect(getSlackInteractive(result)).toEqual([
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [
|
||||
{
|
||||
label: "Approve",
|
||||
value: "pluginbind:approval-123:o",
|
||||
style: "primary",
|
||||
},
|
||||
{
|
||||
label: "Reject",
|
||||
value: "deny",
|
||||
style: "danger",
|
||||
},
|
||||
{
|
||||
label: "Skip",
|
||||
value: "skip",
|
||||
style: "secondary",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves slack_select values that end in style-like suffixes", () => {
|
||||
const result = parseSlackDirectives({
|
||||
text: "[[slack_select: Choose one | Queue:queue:danger, Archive:archive:primary]]",
|
||||
});
|
||||
|
||||
expect(getSlackInteractive(result)).toEqual([
|
||||
{
|
||||
type: "select",
|
||||
placeholder: "Choose one",
|
||||
options: [
|
||||
{
|
||||
label: "Queue",
|
||||
value: "queue:danger",
|
||||
},
|
||||
{
|
||||
label: "Archive",
|
||||
value: "archive:primary",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps existing interactive blocks when compiling additional Slack directives", () => {
|
||||
const result = parseSlackDirectives({
|
||||
text: "Choose [[slack_buttons: Retry:retry]]",
|
||||
interactive: {
|
||||
blocks: [{ type: "text", text: "Existing" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(getSlackInteractive(result)).toEqual([
|
||||
{ type: "text", text: "Existing" },
|
||||
{ type: "text", text: "Choose" },
|
||||
{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores malformed directive choices when none remain", () => {
|
||||
const result = parseSlackDirectives({
|
||||
text: "Choose [[slack_buttons: : , : ]]",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
text: "Choose [[slack_buttons: : , : ]]",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach } from "vitest";
|
||||
import { normalizeWhatsAppAllowFromEntries } from "../../../extensions/whatsapp/api.js";
|
||||
import { normalizeE164 } from "../../plugin-sdk/account-resolution.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
|
||||
@@ -11,6 +11,34 @@ function formatDiscordAllowFromEntries(allowFrom: Array<string | number>): strin
|
||||
.map((entry) => entry.toLowerCase());
|
||||
}
|
||||
|
||||
function normalizePhoneAllowFromEntries(allowFrom: Array<string | number>): string[] {
|
||||
return allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.map((entry) => {
|
||||
if (entry === "*") {
|
||||
return entry;
|
||||
}
|
||||
const stripped = entry.replace(/^whatsapp:/i, "").trim();
|
||||
if (/@g\.us$/i.test(stripped)) {
|
||||
return stripped;
|
||||
}
|
||||
if (/^(\d+)(?::\d+)?@s\.whatsapp\.net$/i.test(stripped)) {
|
||||
const match = stripped.match(/^(\d+)(?::\d+)?@s\.whatsapp\.net$/i);
|
||||
return match ? normalizeE164(match[1]) : null;
|
||||
}
|
||||
if (/^(\d+)@lid$/i.test(stripped)) {
|
||||
const match = stripped.match(/^(\d+)@lid$/i);
|
||||
return match ? normalizeE164(match[1]) : null;
|
||||
}
|
||||
if (stripped.includes("@")) {
|
||||
return null;
|
||||
}
|
||||
return normalizeE164(stripped);
|
||||
})
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
}
|
||||
|
||||
function resolveChannelAllowFrom(
|
||||
cfg: Record<string, unknown>,
|
||||
channelId: string,
|
||||
@@ -52,7 +80,7 @@ export const createCommandAuthRegistry = () =>
|
||||
resolveAllowFrom: ({ cfg }: { cfg: Record<string, unknown> }) =>
|
||||
resolveChannelAllowFrom(cfg, "whatsapp"),
|
||||
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
|
||||
normalizeWhatsAppAllowFromEntries(allowFrom),
|
||||
normalizePhoneAllowFromEntries(allowFrom),
|
||||
},
|
||||
},
|
||||
source: "test",
|
||||
|
||||
@@ -133,12 +133,7 @@ const SETUP_BARREL_GUARDS: GuardedSource[] = [
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("signal", "src/setup-surface.ts"),
|
||||
forbiddenPatterns: [
|
||||
/\bdetectBinary\b/,
|
||||
/\binstallSignalCli\b/,
|
||||
/\bformatCliCommand\b/,
|
||||
/\bformatDocsLink\b/,
|
||||
],
|
||||
forbiddenPatterns: [/\bdetectBinary\b/, /\bformatCliCommand\b/, /\bformatDocsLink\b/],
|
||||
},
|
||||
{
|
||||
path: bundledPluginFile("slack", "src/setup-core.ts"),
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSignalSenderAllowed, type SignalSender } from "../../../../extensions/signal/api.js";
|
||||
import {
|
||||
isSignalSenderAllowed,
|
||||
type SignalSender,
|
||||
} from "../../../../test/helpers/channels/policy-contract.js";
|
||||
import { isAllowedBlueBubblesSender } from "../../../plugin-sdk/bluebubbles-policy.js";
|
||||
import { isMattermostSenderAllowed } from "../../../plugin-sdk/mattermost-policy.js";
|
||||
import {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { vi, type Mock } from "vitest";
|
||||
import type { ReplyPayload } from "../../../auto-reply/types.js";
|
||||
import { loadBundledPluginTestApiSync } from "../../../test-utils/bundled-plugin-public-surface.js";
|
||||
import type { ChannelOutboundAdapter } from "../types.js";
|
||||
import { primeChannelOutboundSendMock } from "./test-helpers.js";
|
||||
|
||||
type OutboundSendMock = Mock<(...args: unknown[]) => Promise<Record<string, unknown>>>;
|
||||
|
||||
type SlackOutboundPayloadHarness = {
|
||||
run: () => Promise<Record<string, unknown>>;
|
||||
sendMock: OutboundSendMock;
|
||||
to: string;
|
||||
};
|
||||
|
||||
let slackOutboundCache: ChannelOutboundAdapter | undefined;
|
||||
|
||||
function getSlackOutbound(): ChannelOutboundAdapter {
|
||||
if (!slackOutboundCache) {
|
||||
({ slackOutbound: slackOutboundCache } = loadBundledPluginTestApiSync<{
|
||||
slackOutbound: ChannelOutboundAdapter;
|
||||
}>("slack"));
|
||||
}
|
||||
return slackOutboundCache;
|
||||
}
|
||||
|
||||
export function createSlackOutboundPayloadHarness(params: {
|
||||
payload: ReplyPayload;
|
||||
sendResults?: Array<{ messageId: string }>;
|
||||
}): SlackOutboundPayloadHarness {
|
||||
const sendSlack: OutboundSendMock = vi.fn();
|
||||
primeChannelOutboundSendMock(
|
||||
sendSlack,
|
||||
{ messageId: "sl-1", channelId: "C12345", ts: "1234.5678" },
|
||||
params.sendResults,
|
||||
);
|
||||
const ctx = {
|
||||
cfg: {},
|
||||
to: "C12345",
|
||||
text: "",
|
||||
payload: params.payload,
|
||||
deps: {
|
||||
sendSlack,
|
||||
},
|
||||
};
|
||||
return {
|
||||
run: async () => await getSlackOutbound().sendPayload!(ctx),
|
||||
sendMock: sendSlack,
|
||||
to: ctx.to,
|
||||
};
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "./imessage.js";
|
||||
import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./whatsapp.js";
|
||||
|
||||
describe("normalize target helpers", () => {
|
||||
describe("iMessage", () => {
|
||||
it("normalizes blank inputs to undefined", () => {
|
||||
expect(normalizeIMessageMessagingTarget(" ")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("detects common iMessage target forms", () => {
|
||||
expect(looksLikeIMessageTargetId("sms:+15555550123")).toBe(true);
|
||||
expect(looksLikeIMessageTargetId("chat_id:123")).toBe(true);
|
||||
expect(looksLikeIMessageTargetId("user@example.com")).toBe(true);
|
||||
expect(looksLikeIMessageTargetId("+15555550123")).toBe(true);
|
||||
expect(looksLikeIMessageTargetId("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WhatsApp", () => {
|
||||
it("normalizes blank inputs to undefined", () => {
|
||||
expect(normalizeWhatsAppMessagingTarget(" ")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("detects common WhatsApp target forms", () => {
|
||||
expect(looksLikeWhatsAppTargetId("whatsapp:+15555550123")).toBe(true);
|
||||
expect(looksLikeWhatsAppTargetId("15555550123@c.us")).toBe(true);
|
||||
expect(looksLikeWhatsAppTargetId("+15555550123")).toBe(true);
|
||||
expect(looksLikeWhatsAppTargetId("")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,17 +3,12 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TEST_BUNDLED_RUNTIME_SIDECAR_PATHS } from "../../test/helpers/bundled-runtime-sidecars.js";
|
||||
import type { OpenClawConfig, ConfigFileSnapshot } from "../config/types.openclaw.js";
|
||||
import type { UpdateRunResult } from "../infra/update-runner.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { createCliRuntimeCapture } from "./test-runtime-capture.js";
|
||||
|
||||
const TEST_BUNDLED_RUNTIME_SIDECAR_PATHS = [
|
||||
"dist/extensions/discord/runtime-api.js",
|
||||
"dist/extensions/slack/helper-api.js",
|
||||
"dist/extensions/telegram/thread-bindings-runtime.js",
|
||||
] as const;
|
||||
|
||||
const confirm = vi.fn();
|
||||
const select = vi.fn();
|
||||
const spinner = vi.fn(() => ({ start: vi.fn(), stop: vi.fn() }));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createIMessageTestPlugin } from "../../test/helpers/channels/imessage-test-plugin.js";
|
||||
import { collectStatusIssuesFromLastError } from "../plugin-sdk/status-helpers.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { createIMessageTestPlugin } from "../test-utils/imessage-test-plugin.js";
|
||||
import { formatGatewayChannelsStatusLines } from "./channels/status.js";
|
||||
|
||||
const signalPlugin = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "../infra/matrix-config-helpers.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "../plugin-sdk/matrix.js";
|
||||
import * as noteModule from "../terminal/note.js";
|
||||
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
||||
import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js";
|
||||
|
||||
@@ -304,10 +304,10 @@ describe("doctor legacy state migrations", () => {
|
||||
it("migrates legacy Telegram pairing allowFrom store to account-scoped default file", async () => {
|
||||
const { root, cfg } = await makeRootWithEmptyCfg();
|
||||
const { oauthDir, detected, result } = await runTelegramAllowFromMigration({ root, cfg });
|
||||
expect(detected.pairingAllowFrom.hasLegacyTelegram).toBe(true);
|
||||
expect(
|
||||
detected.pairingAllowFrom.copyPlans.map((plan) => path.basename(plan.targetPath)),
|
||||
).toEqual(["telegram-default-allowFrom.json"]);
|
||||
expect(detected.channelPlans.hasLegacy).toBe(true);
|
||||
expect(detected.channelPlans.plans.map((plan) => path.basename(plan.targetPath))).toEqual([
|
||||
"telegram-default-allowFrom.json",
|
||||
]);
|
||||
expect(result.warnings).toEqual([]);
|
||||
|
||||
const target = path.join(oauthDir, "telegram-default-allowFrom.json");
|
||||
@@ -332,10 +332,10 @@ describe("doctor legacy state migrations", () => {
|
||||
},
|
||||
};
|
||||
const { oauthDir, detected, result } = await runTelegramAllowFromMigration({ root, cfg });
|
||||
expect(detected.pairingAllowFrom.hasLegacyTelegram).toBe(true);
|
||||
expect(
|
||||
detected.pairingAllowFrom.copyPlans.map((plan) => path.basename(plan.targetPath)),
|
||||
).toEqual(["telegram-bot2-allowFrom.json"]);
|
||||
expect(detected.channelPlans.hasLegacy).toBe(true);
|
||||
expect(detected.channelPlans.plans.map((plan) => path.basename(plan.targetPath))).toEqual([
|
||||
"telegram-bot2-allowFrom.json",
|
||||
]);
|
||||
expect(result.warnings).toEqual([]);
|
||||
|
||||
const bot1Target = path.join(oauthDir, "telegram-bot1-allowFrom.json");
|
||||
@@ -368,10 +368,10 @@ describe("doctor legacy state migrations", () => {
|
||||
};
|
||||
|
||||
const { oauthDir, detected, result } = await runTelegramAllowFromMigration({ root, cfg });
|
||||
expect(detected.pairingAllowFrom.hasLegacyTelegram).toBe(true);
|
||||
expect(
|
||||
detected.pairingAllowFrom.copyPlans.map((plan) => path.basename(plan.targetPath)),
|
||||
).toEqual(["telegram-alerts-allowFrom.json"]);
|
||||
expect(detected.channelPlans.hasLegacy).toBe(true);
|
||||
expect(detected.channelPlans.plans.map((plan) => path.basename(plan.targetPath))).toEqual([
|
||||
"telegram-alerts-allowFrom.json",
|
||||
]);
|
||||
expect(result.warnings).toEqual([]);
|
||||
|
||||
const alertsTarget = path.join(oauthDir, "telegram-alerts-allowFrom.json");
|
||||
|
||||
@@ -160,14 +160,9 @@ function createLegacyStateMigrationDetectionResult(params?: {
|
||||
targetDir: "/tmp/state/agents/main/agent",
|
||||
hasLegacy: false,
|
||||
},
|
||||
whatsappAuth: {
|
||||
legacyDir: "/tmp/oauth",
|
||||
targetDir: "/tmp/oauth/whatsapp/default",
|
||||
channelPlans: {
|
||||
hasLegacy: false,
|
||||
},
|
||||
pairingAllowFrom: {
|
||||
hasLegacyTelegram: false,
|
||||
copyPlans: [],
|
||||
plans: [],
|
||||
},
|
||||
preview: params?.preview ?? [],
|
||||
};
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectMutableAllowlistWarnings,
|
||||
scanMutableAllowlistEntries,
|
||||
} from "./mutable-allowlist.js";
|
||||
|
||||
describe("doctor mutable allowlist scanner", () => {
|
||||
it("finds mutable built-in allowlist entries when dangerous matching is disabled", () => {
|
||||
const hits = scanMutableAllowlistEntries({
|
||||
channels: {
|
||||
irc: {
|
||||
allowFrom: ["charlie"],
|
||||
groups: {
|
||||
"#ops": {
|
||||
allowFrom: ["dana"],
|
||||
},
|
||||
},
|
||||
},
|
||||
googlechat: {
|
||||
groupAllowFrom: ["engineering@example.com"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(hits).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
channel: "irc",
|
||||
path: "channels.irc.allowFrom",
|
||||
entry: "charlie",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
channel: "irc",
|
||||
path: "channels.irc.groups.#ops.allowFrom",
|
||||
entry: "dana",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
channel: "googlechat",
|
||||
path: "channels.googlechat.groupAllowFrom",
|
||||
entry: "engineering@example.com",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips scopes that explicitly allow dangerous name matching", () => {
|
||||
const hits = scanMutableAllowlistEntries({
|
||||
channels: {
|
||||
googlechat: {
|
||||
dangerouslyAllowNameMatching: true,
|
||||
groupAllowFrom: ["engineering@example.com"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
|
||||
it("formats mutable allowlist warnings", () => {
|
||||
const warnings = collectMutableAllowlistWarnings([
|
||||
{
|
||||
channel: "irc",
|
||||
path: "channels.irc.allowFrom",
|
||||
entry: "bob",
|
||||
dangerousFlagPath: "channels.irc.dangerouslyAllowNameMatching",
|
||||
},
|
||||
{
|
||||
channel: "googlechat",
|
||||
path: "channels.googlechat.groupAllowFrom",
|
||||
entry: "engineering@example.com",
|
||||
dangerousFlagPath: "channels.googlechat.dangerouslyAllowNameMatching",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(warnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("mutable allowlist entries across googlechat, irc"),
|
||||
expect.stringContaining("channels.irc.allowFrom: bob"),
|
||||
expect.stringContaining("channels.googlechat.groupAllowFrom: engineering@example.com"),
|
||||
expect.stringContaining("Option A"),
|
||||
expect.stringContaining("Option B"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { matrixSetupAdapter, matrixSetupWizard } from "../../test/helpers/plugins/matrix-setup.js";
|
||||
import {
|
||||
matrixSetupAdapter,
|
||||
matrixSetupWizard,
|
||||
} from "../../test/helpers/channels/matrix-setup-contract.js";
|
||||
import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry.js";
|
||||
|
||||
167
src/config/channel-token-and-http.validation.test.ts
Normal file
167
src/config/channel-token-and-http.validation.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateConfigObject } from "./config.js";
|
||||
|
||||
describe("channel token and HTTP validation", () => {
|
||||
describe("Slack token fields", () => {
|
||||
it("accepts user token config fields", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-any",
|
||||
appToken: "xapp-any",
|
||||
userToken: "xoxp-any",
|
||||
userTokenReadOnly: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts account-level user token config", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
work: {
|
||||
botToken: "xoxb-any",
|
||||
appToken: "xapp-any",
|
||||
userToken: "xoxp-any",
|
||||
userTokenReadOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid userTokenReadOnly types", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-any",
|
||||
appToken: "xapp-any",
|
||||
userToken: "xoxp-any",
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
userTokenReadOnly: "no" as any,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues.some((iss) => iss.path.includes("userTokenReadOnly"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid userToken types", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-any",
|
||||
appToken: "xapp-any",
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
userToken: 123 as any,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues.some((iss) => iss.path.includes("userToken"))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Slack HTTP mode", () => {
|
||||
it("accepts HTTP mode when signing secret is configured", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
mode: "http",
|
||||
signingSecret: "secret",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts HTTP mode when signing secret is configured as SecretRef", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
mode: "http",
|
||||
signingSecret: { source: "env", provider: "default", id: "SLACK_SIGNING_SECRET" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects HTTP mode without signing secret", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
mode: "http",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("channels.slack.signingSecret");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts account HTTP mode when base signing secret is set", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
signingSecret: "secret",
|
||||
accounts: {
|
||||
ops: {
|
||||
mode: "http",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts account HTTP mode when account signing secret is set as SecretRef", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
ops: {
|
||||
mode: "http",
|
||||
signingSecret: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "SLACK_OPS_SIGNING_SECRET",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects account HTTP mode without signing secret", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
ops: {
|
||||
mode: "http",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("channels.slack.accounts.ops.signingSecret");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
175
src/config/channel-webhook-and-actions.validation.test.ts
Normal file
175
src/config/channel-webhook-and-actions.validation.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateConfigObject } from "./config.js";
|
||||
|
||||
describe("channel webhook and actions validation", () => {
|
||||
describe("Telegram poll actions", () => {
|
||||
it("accepts channels.telegram.actions.poll", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
telegram: {
|
||||
actions: {
|
||||
poll: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts channels.telegram.accounts.<id>.actions.poll", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
ops: {
|
||||
actions: {
|
||||
poll: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Telegram webhookPort", () => {
|
||||
it("accepts a positive webhookPort", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
telegram: {
|
||||
webhookUrl: "https://example.com/telegram-webhook",
|
||||
webhookSecret: "secret",
|
||||
webhookPort: 8787,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts webhookPort set to 0 for ephemeral port binding", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
telegram: {
|
||||
webhookUrl: "https://example.com/telegram-webhook",
|
||||
webhookSecret: "secret",
|
||||
webhookPort: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects negative webhookPort", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
telegram: {
|
||||
webhookUrl: "https://example.com/telegram-webhook",
|
||||
webhookSecret: "secret",
|
||||
webhookPort: -1,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues.some((issue) => issue.path === "channels.telegram.webhookPort")).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Telegram webhook secret", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "webhookUrl when webhookSecret is configured",
|
||||
config: {
|
||||
telegram: {
|
||||
webhookUrl: "https://example.com/telegram-webhook",
|
||||
webhookSecret: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "webhookUrl when webhookSecret is configured as SecretRef",
|
||||
config: {
|
||||
telegram: {
|
||||
webhookUrl: "https://example.com/telegram-webhook",
|
||||
webhookSecret: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "TELEGRAM_WEBHOOK_SECRET",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "account webhookUrl when base webhookSecret is configured",
|
||||
config: {
|
||||
telegram: {
|
||||
webhookSecret: "secret",
|
||||
accounts: {
|
||||
ops: {
|
||||
webhookUrl: "https://example.com/telegram-webhook",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "account webhookUrl when account webhookSecret is configured as SecretRef",
|
||||
config: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
ops: {
|
||||
webhookUrl: "https://example.com/telegram-webhook",
|
||||
webhookSecret: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "TELEGRAM_OPS_WEBHOOK_SECRET",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const)("accepts $name", ({ config }) => {
|
||||
expect(validateConfigObject({ channels: config }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects webhookUrl without webhookSecret", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
telegram: {
|
||||
webhookUrl: "https://example.com/telegram-webhook",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("channels.telegram.webhookSecret");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects account webhookUrl without webhookSecret", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
ops: {
|
||||
webhookUrl: "https://example.com/telegram-webhook",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("channels.telegram.accounts.ops.webhookSecret");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDiscordPreviewStreamMode } from "./discord-preview-streaming.js";
|
||||
|
||||
describe("resolveDiscordPreviewStreamMode", () => {
|
||||
it("defaults to off when unset", () => {
|
||||
expect(resolveDiscordPreviewStreamMode({})).toBe("off");
|
||||
});
|
||||
|
||||
it("preserves explicit off", () => {
|
||||
expect(resolveDiscordPreviewStreamMode({ streaming: "off" })).toBe("off");
|
||||
expect(resolveDiscordPreviewStreamMode({ streamMode: "off" })).toBe("off");
|
||||
expect(resolveDiscordPreviewStreamMode({ streaming: false })).toBe("off");
|
||||
});
|
||||
});
|
||||
@@ -1,161 +0,0 @@
|
||||
export type StreamingMode = "off" | "partial" | "block" | "progress";
|
||||
export type DiscordPreviewStreamMode = "off" | "partial" | "block";
|
||||
export type TelegramPreviewStreamMode = "off" | "partial" | "block";
|
||||
export type SlackLegacyDraftStreamMode = "replace" | "status_final" | "append";
|
||||
|
||||
function normalizeStreamingMode(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
export function parseStreamingMode(value: unknown): StreamingMode | null {
|
||||
const normalized = normalizeStreamingMode(value);
|
||||
if (
|
||||
normalized === "off" ||
|
||||
normalized === "partial" ||
|
||||
normalized === "block" ||
|
||||
normalized === "progress"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseDiscordPreviewStreamMode(value: unknown): DiscordPreviewStreamMode | null {
|
||||
const parsed = parseStreamingMode(value);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return parsed === "progress" ? "partial" : parsed;
|
||||
}
|
||||
|
||||
export function parseSlackLegacyDraftStreamMode(value: unknown): SlackLegacyDraftStreamMode | null {
|
||||
const normalized = normalizeStreamingMode(value);
|
||||
if (normalized === "replace" || normalized === "status_final" || normalized === "append") {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function mapSlackLegacyDraftStreamModeToStreaming(
|
||||
mode: SlackLegacyDraftStreamMode,
|
||||
): StreamingMode {
|
||||
if (mode === "append") {
|
||||
return "block";
|
||||
}
|
||||
if (mode === "status_final") {
|
||||
return "progress";
|
||||
}
|
||||
return "partial";
|
||||
}
|
||||
|
||||
export function mapStreamingModeToSlackLegacyDraftStreamMode(mode: StreamingMode) {
|
||||
if (mode === "block") {
|
||||
return "append" as const;
|
||||
}
|
||||
if (mode === "progress") {
|
||||
return "status_final" as const;
|
||||
}
|
||||
return "replace" as const;
|
||||
}
|
||||
|
||||
export function resolveTelegramPreviewStreamMode(
|
||||
params: {
|
||||
streamMode?: unknown;
|
||||
streaming?: unknown;
|
||||
} = {},
|
||||
): TelegramPreviewStreamMode {
|
||||
const parsedStreaming = parseStreamingMode(params.streaming);
|
||||
if (parsedStreaming) {
|
||||
if (parsedStreaming === "progress") {
|
||||
return "partial";
|
||||
}
|
||||
return parsedStreaming;
|
||||
}
|
||||
|
||||
const legacy = parseDiscordPreviewStreamMode(params.streamMode);
|
||||
if (legacy) {
|
||||
return legacy;
|
||||
}
|
||||
if (typeof params.streaming === "boolean") {
|
||||
return params.streaming ? "partial" : "off";
|
||||
}
|
||||
return "partial";
|
||||
}
|
||||
|
||||
export function resolveDiscordPreviewStreamMode(
|
||||
params: {
|
||||
streamMode?: unknown;
|
||||
streaming?: unknown;
|
||||
} = {},
|
||||
): DiscordPreviewStreamMode {
|
||||
const parsedStreaming = parseDiscordPreviewStreamMode(params.streaming);
|
||||
if (parsedStreaming) {
|
||||
return parsedStreaming;
|
||||
}
|
||||
|
||||
const legacy = parseDiscordPreviewStreamMode(params.streamMode);
|
||||
if (legacy) {
|
||||
return legacy;
|
||||
}
|
||||
if (typeof params.streaming === "boolean") {
|
||||
return params.streaming ? "partial" : "off";
|
||||
}
|
||||
// Discord preview streaming edits can hit aggressive rate limits, especially
|
||||
// when multiple gateways or multiple bots share the same account/server. Keep
|
||||
// the default off unless the operator opts in explicitly.
|
||||
return "off";
|
||||
}
|
||||
|
||||
export function resolveSlackStreamingMode(
|
||||
params: {
|
||||
streamMode?: unknown;
|
||||
streaming?: unknown;
|
||||
} = {},
|
||||
): StreamingMode {
|
||||
const parsedStreaming = parseStreamingMode(params.streaming);
|
||||
if (parsedStreaming) {
|
||||
return parsedStreaming;
|
||||
}
|
||||
const legacyStreamMode = parseSlackLegacyDraftStreamMode(params.streamMode);
|
||||
if (legacyStreamMode) {
|
||||
return mapSlackLegacyDraftStreamModeToStreaming(legacyStreamMode);
|
||||
}
|
||||
// Legacy boolean `streaming` values map to the unified enum.
|
||||
if (typeof params.streaming === "boolean") {
|
||||
return params.streaming ? "partial" : "off";
|
||||
}
|
||||
return "partial";
|
||||
}
|
||||
|
||||
export function resolveSlackNativeStreaming(
|
||||
params: {
|
||||
nativeStreaming?: unknown;
|
||||
streaming?: unknown;
|
||||
} = {},
|
||||
): boolean {
|
||||
if (typeof params.nativeStreaming === "boolean") {
|
||||
return params.nativeStreaming;
|
||||
}
|
||||
if (typeof params.streaming === "boolean") {
|
||||
return params.streaming;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function formatSlackStreamModeMigrationMessage(
|
||||
pathPrefix: string,
|
||||
resolvedStreaming: string,
|
||||
): string {
|
||||
return `Moved ${pathPrefix}.streamMode → ${pathPrefix}.streaming (${resolvedStreaming}).`;
|
||||
}
|
||||
|
||||
export function formatSlackStreamingBooleanMigrationMessage(
|
||||
pathPrefix: string,
|
||||
resolvedNativeStreaming: boolean,
|
||||
): string {
|
||||
return `Moved ${pathPrefix}.streaming (boolean) → ${pathPrefix}.nativeStreaming (${resolvedNativeStreaming}).`;
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateConfigObject } from "./config.js";
|
||||
|
||||
describe("Slack HTTP mode config", () => {
|
||||
it("accepts HTTP mode when signing secret is configured", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
mode: "http",
|
||||
signingSecret: "secret",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts HTTP mode when signing secret is configured as SecretRef", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
mode: "http",
|
||||
signingSecret: { source: "env", provider: "default", id: "SLACK_SIGNING_SECRET" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects HTTP mode without signing secret", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
mode: "http",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("channels.slack.signingSecret");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts account HTTP mode when base signing secret is set", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
signingSecret: "secret",
|
||||
accounts: {
|
||||
ops: {
|
||||
mode: "http",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts account HTTP mode when account signing secret is set as SecretRef", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
ops: {
|
||||
mode: "http",
|
||||
signingSecret: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "SLACK_OPS_SIGNING_SECRET",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects account HTTP mode without signing secret", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
ops: {
|
||||
mode: "http",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("channels.slack.accounts.ops.signingSecret");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateConfigObject } from "./config.js";
|
||||
|
||||
describe("Slack token config fields", () => {
|
||||
it("accepts user token config fields", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-any",
|
||||
appToken: "xapp-any",
|
||||
userToken: "xoxp-any",
|
||||
userTokenReadOnly: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts account-level user token config", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
work: {
|
||||
botToken: "xoxb-any",
|
||||
appToken: "xapp-any",
|
||||
userToken: "xoxp-any",
|
||||
userTokenReadOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid userTokenReadOnly types", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-any",
|
||||
appToken: "xapp-any",
|
||||
userToken: "xoxp-any",
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
userTokenReadOnly: "no" as any,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues.some((iss) => iss.path.includes("userTokenReadOnly"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid userToken types", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-any",
|
||||
appToken: "xapp-any",
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
userToken: 123 as any,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues.some((iss) => iss.path.includes("userToken"))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateConfigObject } from "./config.js";
|
||||
|
||||
describe("telegram poll action config", () => {
|
||||
it("accepts channels.telegram.actions.poll", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
telegram: {
|
||||
actions: {
|
||||
poll: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts channels.telegram.accounts.<id>.actions.poll", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
ops: {
|
||||
actions: {
|
||||
poll: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateConfigObject } from "./config.js";
|
||||
|
||||
describe("Telegram webhookPort config", () => {
|
||||
it("accepts a positive webhookPort", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
telegram: {
|
||||
webhookUrl: "https://example.com/telegram-webhook",
|
||||
webhookSecret: "secret", // pragma: allowlist secret
|
||||
webhookPort: 8787,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts webhookPort set to 0 for ephemeral port binding", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
telegram: {
|
||||
webhookUrl: "https://example.com/telegram-webhook",
|
||||
webhookSecret: "secret", // pragma: allowlist secret
|
||||
webhookPort: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects negative webhookPort", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
telegram: {
|
||||
webhookUrl: "https://example.com/telegram-webhook",
|
||||
webhookSecret: "secret", // pragma: allowlist secret
|
||||
webhookPort: -1,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues.some((issue) => issue.path === "channels.telegram.webhookPort")).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateConfigObject } from "./config.js";
|
||||
|
||||
describe("Telegram webhook config", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "webhookUrl when webhookSecret is configured",
|
||||
config: {
|
||||
telegram: {
|
||||
webhookUrl: "https://example.com/telegram-webhook",
|
||||
webhookSecret: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "webhookUrl when webhookSecret is configured as SecretRef",
|
||||
config: {
|
||||
telegram: {
|
||||
webhookUrl: "https://example.com/telegram-webhook",
|
||||
webhookSecret: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "TELEGRAM_WEBHOOK_SECRET",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "account webhookUrl when base webhookSecret is configured",
|
||||
config: {
|
||||
telegram: {
|
||||
webhookSecret: "secret",
|
||||
accounts: {
|
||||
ops: {
|
||||
webhookUrl: "https://example.com/telegram-webhook",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "account webhookUrl when account webhookSecret is configured as SecretRef",
|
||||
config: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
ops: {
|
||||
webhookUrl: "https://example.com/telegram-webhook",
|
||||
webhookSecret: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "TELEGRAM_OPS_WEBHOOK_SECRET",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const)("accepts $name", ({ config }) => {
|
||||
expect(validateConfigObject({ channels: config }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects webhookUrl without webhookSecret", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
telegram: {
|
||||
webhookUrl: "https://example.com/telegram-webhook",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("channels.telegram.webhookSecret");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects account webhookUrl without webhookSecret", () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
ops: {
|
||||
webhookUrl: "https://example.com/telegram-webhook",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("channels.telegram.accounts.ops.webhookSecret");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { createIMessageTestPlugin } from "../../test/helpers/channels/imessage-test-plugin.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { createIMessageTestPlugin } from "../test-utils/imessage-test-plugin.js";
|
||||
import {
|
||||
extractHookToken,
|
||||
isHookAgentAllowed,
|
||||
|
||||
@@ -2,6 +2,10 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isWhatsAppGroupJid,
|
||||
normalizeWhatsAppTarget,
|
||||
} from "../../test/helpers/channels/command-contract.js";
|
||||
import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
@@ -10,7 +14,6 @@ import {
|
||||
resolveMainSessionKey,
|
||||
resolveStorePath,
|
||||
} from "../config/sessions.js";
|
||||
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../plugin-sdk/whatsapp-targets.js";
|
||||
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { buildAgentPeerSessionKey } from "../routing/session-key.js";
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
findMatrixAccountEntry,
|
||||
getMatrixScopedEnvVarNames,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "./matrix-config-helpers.js";
|
||||
|
||||
describe("matrix account selection", () => {
|
||||
it("resolves configured account ids from non-canonical account keys", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
"Team Ops": { homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg)).toEqual(["team-ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops");
|
||||
});
|
||||
|
||||
it("matches the default account against normalized Matrix account keys", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "Team Ops",
|
||||
accounts: {
|
||||
"Ops Bot": { homeserver: "https://matrix.example.org" },
|
||||
"Team Ops": { homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires an explicit default when multiple Matrix accounts exist without one", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: { homeserver: "https://matrix.example.org" },
|
||||
alerts: { homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(true);
|
||||
});
|
||||
|
||||
it("finds the raw Matrix account entry by normalized account id", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
"Team Ops": {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(findMatrixAccountEntry(cfg, "team-ops")).toEqual({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("discovers env-backed named Matrix accounts during enumeration", () => {
|
||||
const keys = getMatrixScopedEnvVarNames("team-ops");
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
};
|
||||
const env = {
|
||||
[keys.homeserver]: "https://matrix.example.org",
|
||||
[keys.accessToken]: "secret",
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["team-ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("team-ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats mixed default and named env-backed Matrix accounts as multi-account", () => {
|
||||
const keys = getMatrixScopedEnvVarNames("team-ops");
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
};
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_ACCESS_TOKEN: "default-secret",
|
||||
[keys.homeserver]: "https://matrix.example.org",
|
||||
[keys.accessToken]: "team-secret",
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default", "team-ops"]);
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(true);
|
||||
});
|
||||
|
||||
it("discovers default Matrix accounts backed only by global env vars", () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_ACCESS_TOKEN: "default-secret",
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default");
|
||||
});
|
||||
});
|
||||
@@ -1,440 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "./matrix-config-helpers.js";
|
||||
import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js";
|
||||
import { MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE } from "./matrix-plugin-helper.js";
|
||||
import {
|
||||
MATRIX_DEFAULT_ACCESS_TOKEN,
|
||||
MATRIX_DEFAULT_DEVICE_ID,
|
||||
MATRIX_DEFAULT_USER_ID,
|
||||
MATRIX_OPS_ACCESS_TOKEN,
|
||||
MATRIX_OPS_ACCOUNT_ID,
|
||||
MATRIX_OPS_DEVICE_ID,
|
||||
MATRIX_OPS_USER_ID,
|
||||
MATRIX_TEST_HOMESERVER,
|
||||
matrixHelperEnv,
|
||||
writeFile,
|
||||
writeMatrixCredentials,
|
||||
writeMatrixPluginFixture,
|
||||
} from "./matrix.test-helpers.js";
|
||||
|
||||
vi.unmock("../version.js");
|
||||
|
||||
function createDefaultMatrixConfig(): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: MATRIX_DEFAULT_USER_ID,
|
||||
accessToken: MATRIX_DEFAULT_ACCESS_TOKEN,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeDefaultLegacyCryptoFixture(home: string) {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const cfg = createDefaultMatrixConfig();
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: MATRIX_DEFAULT_USER_ID,
|
||||
accessToken: MATRIX_DEFAULT_ACCESS_TOKEN,
|
||||
});
|
||||
writeFile(
|
||||
path.join(rootDir, "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: MATRIX_DEFAULT_DEVICE_ID }),
|
||||
);
|
||||
return { cfg, rootDir, stateDir };
|
||||
}
|
||||
|
||||
function createOpsLegacyCryptoFixture(params: {
|
||||
home: string;
|
||||
cfg: OpenClawConfig;
|
||||
accessToken?: string;
|
||||
includeStoredCredentials?: boolean;
|
||||
}) {
|
||||
const stateDir = path.join(params.home, ".openclaw");
|
||||
writeMatrixPluginFixture(path.join(params.home, "bundled", "matrix"));
|
||||
writeFile(
|
||||
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: MATRIX_OPS_DEVICE_ID }),
|
||||
);
|
||||
if (params.includeStoredCredentials) {
|
||||
writeMatrixCredentials(stateDir, {
|
||||
accountId: MATRIX_OPS_ACCOUNT_ID,
|
||||
accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
|
||||
deviceId: MATRIX_OPS_DEVICE_ID,
|
||||
});
|
||||
}
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: MATRIX_OPS_USER_ID,
|
||||
accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
|
||||
accountId: MATRIX_OPS_ACCOUNT_ID,
|
||||
});
|
||||
return { rootDir, stateDir };
|
||||
}
|
||||
|
||||
async function expectPreparedOpsLegacyMigration(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
rootDir: string;
|
||||
inspectLegacyStore: {
|
||||
deviceId: string;
|
||||
roomKeyCounts: { total: number; backedUp: number };
|
||||
backupVersion: string;
|
||||
decryptionKeyBase64: string;
|
||||
};
|
||||
expectAccountId?: boolean;
|
||||
}) {
|
||||
const detection = detectLegacyMatrixCrypto({ cfg: params.cfg, env: params.env });
|
||||
expect(detection.warnings).toEqual([]);
|
||||
expect(detection.plans).toHaveLength(1);
|
||||
expect(detection.plans[0]?.accountId).toBe("ops");
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
deps: {
|
||||
inspectLegacyStore: async () => params.inspectLegacyStore,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toEqual([]);
|
||||
const recovery = JSON.parse(
|
||||
fs.readFileSync(path.join(params.rootDir, "recovery-key.json"), "utf8"),
|
||||
) as {
|
||||
privateKeyBase64: string;
|
||||
};
|
||||
expect(recovery.privateKeyBase64).toBe(params.inspectLegacyStore.decryptionKeyBase64);
|
||||
if (!params.expectAccountId) {
|
||||
return;
|
||||
}
|
||||
const state = JSON.parse(
|
||||
fs.readFileSync(path.join(params.rootDir, "legacy-crypto-migration.json"), "utf8"),
|
||||
) as {
|
||||
accountId: string;
|
||||
};
|
||||
expect(state.accountId).toBe("ops");
|
||||
}
|
||||
|
||||
describe("matrix legacy encrypted-state migration", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("extracts a saved backup key into the new recovery-key path", async () => {
|
||||
await withTempHome(
|
||||
async (home) => {
|
||||
writeMatrixPluginFixture(path.join(home, "bundled", "matrix"));
|
||||
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
|
||||
|
||||
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
|
||||
expect(detection.warnings).toEqual([]);
|
||||
expect(detection.plans).toHaveLength(1);
|
||||
|
||||
const inspectLegacyStore = vi.fn(async () => ({
|
||||
deviceId: MATRIX_DEFAULT_DEVICE_ID,
|
||||
roomKeyCounts: { total: 12, backedUp: 12 },
|
||||
backupVersion: "1",
|
||||
decryptionKeyBase64: "YWJjZA==",
|
||||
}));
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
deps: { inspectLegacyStore },
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(inspectLegacyStore).toHaveBeenCalledOnce();
|
||||
|
||||
const recovery = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"),
|
||||
) as {
|
||||
privateKeyBase64: string;
|
||||
};
|
||||
expect(recovery.privateKeyBase64).toBe("YWJjZA==");
|
||||
|
||||
const state = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
|
||||
) as {
|
||||
restoreStatus: string;
|
||||
decryptionKeyImported: boolean;
|
||||
};
|
||||
expect(state.restoreStatus).toBe("pending");
|
||||
expect(state.decryptionKeyImported).toBe(true);
|
||||
},
|
||||
{ env: matrixHelperEnv },
|
||||
);
|
||||
});
|
||||
|
||||
it("skips inspector loading when no legacy Matrix plans exist", async () => {
|
||||
await withTempHome(
|
||||
async () => {
|
||||
const matrixHelperModule = await import("./matrix-plugin-helper.js");
|
||||
const loadInspectorSpy = vi.spyOn(matrixHelperModule, "loadMatrixLegacyCryptoInspector");
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg: createDefaultMatrixConfig(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
migrated: false,
|
||||
changes: [],
|
||||
warnings: [],
|
||||
});
|
||||
expect(result.warnings).not.toContain(MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE);
|
||||
expect(loadInspectorSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
{
|
||||
env: {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "empty-bundled"),
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when legacy local-only room keys cannot be recovered automatically", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
deps: {
|
||||
inspectLegacyStore: async () => ({
|
||||
deviceId: MATRIX_DEFAULT_DEVICE_ID,
|
||||
roomKeyCounts: { total: 15, backedUp: 10 },
|
||||
backupVersion: null,
|
||||
decryptionKeyBase64: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toContain(
|
||||
'Legacy Matrix encrypted state for account "default" contains 5 room key(s) that were never backed up. Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.',
|
||||
);
|
||||
expect(result.warnings).toContain(
|
||||
'Legacy Matrix encrypted state for account "default" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.',
|
||||
);
|
||||
const state = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
|
||||
) as {
|
||||
restoreStatus: string;
|
||||
};
|
||||
expect(state.restoreStatus).toBe("manual-action-required");
|
||||
});
|
||||
});
|
||||
|
||||
it("warns instead of throwing when recovery-key persistence fails", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
deps: {
|
||||
inspectLegacyStore: async () => ({
|
||||
deviceId: MATRIX_DEFAULT_DEVICE_ID,
|
||||
roomKeyCounts: { total: 12, backedUp: 12 },
|
||||
backupVersion: "1",
|
||||
decryptionKeyBase64: "YWJjZA==",
|
||||
}),
|
||||
writeJsonFileAtomically: async (filePath) => {
|
||||
if (filePath.endsWith("recovery-key.json")) {
|
||||
throw new Error("disk full");
|
||||
}
|
||||
writeFile(filePath, JSON.stringify({ ok: true }, null, 2));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(false);
|
||||
expect(result.warnings).toContain(
|
||||
`Failed writing Matrix recovery key for account "default" (${path.join(rootDir, "recovery-key.json")}): Error: disk full`,
|
||||
);
|
||||
expect(fs.existsSync(path.join(rootDir, "recovery-key.json"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(rootDir, "legacy-crypto-migration.json"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("prepares flat legacy crypto for the only configured non-default Matrix account", async () => {
|
||||
await withTempHome(
|
||||
async (home) => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: MATRIX_OPS_USER_ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { rootDir } = createOpsLegacyCryptoFixture({
|
||||
home,
|
||||
cfg,
|
||||
includeStoredCredentials: true,
|
||||
});
|
||||
|
||||
await expectPreparedOpsLegacyMigration({
|
||||
cfg,
|
||||
env: process.env,
|
||||
rootDir,
|
||||
inspectLegacyStore: {
|
||||
deviceId: MATRIX_OPS_DEVICE_ID,
|
||||
roomKeyCounts: { total: 6, backedUp: 6 },
|
||||
backupVersion: "21868",
|
||||
decryptionKeyBase64: "YWJjZA==",
|
||||
},
|
||||
expectAccountId: true,
|
||||
});
|
||||
},
|
||||
{ env: matrixHelperEnv },
|
||||
);
|
||||
});
|
||||
|
||||
it("uses scoped Matrix env vars when resolving flat legacy crypto migration", async () => {
|
||||
await withTempHome(
|
||||
async (home) => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { rootDir } = createOpsLegacyCryptoFixture({
|
||||
home,
|
||||
cfg,
|
||||
accessToken: "tok-ops-env",
|
||||
});
|
||||
|
||||
await expectPreparedOpsLegacyMigration({
|
||||
cfg,
|
||||
env: process.env,
|
||||
rootDir,
|
||||
inspectLegacyStore: {
|
||||
deviceId: MATRIX_OPS_DEVICE_ID,
|
||||
roomKeyCounts: { total: 4, backedUp: 4 },
|
||||
backupVersion: "9001",
|
||||
decryptionKeyBase64: "YWJjZA==",
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
env: {
|
||||
...matrixHelperEnv,
|
||||
MATRIX_OPS_HOMESERVER: MATRIX_TEST_HOMESERVER,
|
||||
MATRIX_OPS_USER_ID,
|
||||
MATRIX_OPS_ACCESS_TOKEN: "tok-ops-env",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("requires channels.matrix.defaultAccount before preparing flat legacy crypto for one of multiple accounts", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(
|
||||
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: MATRIX_OPS_DEVICE_ID }),
|
||||
);
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: MATRIX_OPS_USER_ID,
|
||||
accessToken: MATRIX_OPS_ACCESS_TOKEN,
|
||||
},
|
||||
alerts: {
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: "@alerts-bot:example.org",
|
||||
accessToken: "tok-alerts",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
|
||||
expect(detection.plans).toHaveLength(0);
|
||||
expect(detection.warnings).toContain(
|
||||
"Legacy Matrix encrypted state detected at " +
|
||||
path.join(stateDir, "matrix", "crypto") +
|
||||
', but multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set. Set "channels.matrix.defaultAccount" to the intended target account before rerunning "openclaw doctor --fix" or restarting the gateway.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("warns instead of throwing when a legacy crypto path is a file", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(path.join(stateDir, "matrix", "crypto"), "not-a-directory");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
|
||||
expect(detection.plans).toHaveLength(0);
|
||||
expect(detection.warnings).toContain(
|
||||
`Legacy Matrix encrypted state path exists but is not a directory: ${path.join(stateDir, "matrix", "crypto")}. OpenClaw skipped automatic crypto migration for that path.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a missing matrix plugin helper once when encrypted-state migration cannot run", async () => {
|
||||
await withTempHome(
|
||||
async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(
|
||||
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: MATRIX_DEFAULT_DEVICE_ID }),
|
||||
);
|
||||
|
||||
const cfg = createDefaultMatrixConfig();
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(false);
|
||||
expect(
|
||||
result.warnings.filter(
|
||||
(warning) => warning === MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
},
|
||||
{
|
||||
env: {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "empty-bundled"),
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,513 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { writeJsonFileAtomically as writeJsonFileAtomicallyImpl } from "../plugin-sdk/json-store.js";
|
||||
import {
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixLegacyFlatStoragePaths,
|
||||
} from "./matrix-config-helpers.js";
|
||||
import {
|
||||
resolveLegacyMatrixFlatStoreTarget,
|
||||
resolveMatrixMigrationAccountTarget,
|
||||
} from "./matrix-migration-config.js";
|
||||
import {
|
||||
MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE,
|
||||
isMatrixLegacyCryptoInspectorAvailable,
|
||||
loadMatrixLegacyCryptoInspector,
|
||||
type MatrixLegacyCryptoInspector,
|
||||
} from "./matrix-plugin-helper.js";
|
||||
|
||||
type MatrixLegacyCryptoCounts = {
|
||||
total: number;
|
||||
backedUp: number;
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoSummary = {
|
||||
deviceId: string | null;
|
||||
roomKeyCounts: MatrixLegacyCryptoCounts | null;
|
||||
backupVersion: string | null;
|
||||
decryptionKeyBase64: string | null;
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoMigrationState = {
|
||||
version: 1;
|
||||
source: "matrix-bot-sdk-rust";
|
||||
accountId: string;
|
||||
deviceId: string | null;
|
||||
roomKeyCounts: MatrixLegacyCryptoCounts | null;
|
||||
backupVersion: string | null;
|
||||
decryptionKeyImported: boolean;
|
||||
restoreStatus: "pending" | "completed" | "manual-action-required";
|
||||
detectedAt: string;
|
||||
restoredAt?: string;
|
||||
importedCount?: number;
|
||||
totalCount?: number;
|
||||
lastError?: string | null;
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoPlan = {
|
||||
accountId: string;
|
||||
rootDir: string;
|
||||
recoveryKeyPath: string;
|
||||
statePath: string;
|
||||
legacyCryptoPath: string;
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
deviceId: string | null;
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoDetection = {
|
||||
plans: MatrixLegacyCryptoPlan[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoPreparationResult = {
|
||||
migrated: boolean;
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoPrepareDeps = {
|
||||
inspectLegacyStore: MatrixLegacyCryptoInspector;
|
||||
writeJsonFileAtomically: typeof writeJsonFileAtomicallyImpl;
|
||||
};
|
||||
|
||||
type MatrixLegacyBotSdkMetadata = {
|
||||
deviceId: string | null;
|
||||
};
|
||||
|
||||
type MatrixStoredRecoveryKey = {
|
||||
version: 1;
|
||||
createdAt: string;
|
||||
keyId?: string | null;
|
||||
encodedPrivateKey?: string;
|
||||
privateKeyBase64: string;
|
||||
keyInfo?: {
|
||||
passphrase?: unknown;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function detectLegacyBotSdkCryptoStore(cryptoRootDir: string): {
|
||||
detected: boolean;
|
||||
warning?: string;
|
||||
} {
|
||||
try {
|
||||
const stat = fs.statSync(cryptoRootDir);
|
||||
if (!stat.isDirectory()) {
|
||||
return {
|
||||
detected: false,
|
||||
warning:
|
||||
`Legacy Matrix encrypted state path exists but is not a directory: ${cryptoRootDir}. ` +
|
||||
"OpenClaw skipped automatic crypto migration for that path.",
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
detected: false,
|
||||
warning:
|
||||
`Failed reading legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` +
|
||||
"OpenClaw skipped automatic crypto migration for that path.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
detected:
|
||||
fs.existsSync(path.join(cryptoRootDir, "bot-sdk.json")) ||
|
||||
fs.existsSync(path.join(cryptoRootDir, "matrix-sdk-crypto.sqlite3")) ||
|
||||
fs
|
||||
.readdirSync(cryptoRootDir, { withFileTypes: true })
|
||||
.some(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
fs.existsSync(path.join(cryptoRootDir, entry.name, "matrix-sdk-crypto.sqlite3")),
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
detected: false,
|
||||
warning:
|
||||
`Failed scanning legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` +
|
||||
"OpenClaw skipped automatic crypto migration for that path.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMatrixAccountIds(cfg: OpenClawConfig): string[] {
|
||||
return resolveConfiguredMatrixAccountIds(cfg);
|
||||
}
|
||||
|
||||
function resolveLegacyMatrixFlatStorePlan(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): MatrixLegacyCryptoPlan | { warning: string } | null {
|
||||
const legacy = resolveMatrixLegacyFlatStoragePaths(resolveStateDir(params.env, os.homedir));
|
||||
if (!fs.existsSync(legacy.cryptoPath)) {
|
||||
return null;
|
||||
}
|
||||
const legacyStore = detectLegacyBotSdkCryptoStore(legacy.cryptoPath);
|
||||
if (legacyStore.warning) {
|
||||
return { warning: legacyStore.warning };
|
||||
}
|
||||
if (!legacyStore.detected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = resolveLegacyMatrixFlatStoreTarget({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
detectedPath: legacy.cryptoPath,
|
||||
detectedKind: "encrypted state",
|
||||
});
|
||||
if ("warning" in target) {
|
||||
return target;
|
||||
}
|
||||
|
||||
const metadata = loadLegacyBotSdkMetadata(legacy.cryptoPath);
|
||||
return {
|
||||
accountId: target.accountId,
|
||||
rootDir: target.rootDir,
|
||||
recoveryKeyPath: path.join(target.rootDir, "recovery-key.json"),
|
||||
statePath: path.join(target.rootDir, "legacy-crypto-migration.json"),
|
||||
legacyCryptoPath: legacy.cryptoPath,
|
||||
homeserver: target.homeserver,
|
||||
userId: target.userId,
|
||||
accessToken: target.accessToken,
|
||||
deviceId: metadata.deviceId ?? target.storedDeviceId,
|
||||
};
|
||||
}
|
||||
|
||||
function loadLegacyBotSdkMetadata(cryptoRootDir: string): MatrixLegacyBotSdkMetadata {
|
||||
const metadataPath = path.join(cryptoRootDir, "bot-sdk.json");
|
||||
const fallback: MatrixLegacyBotSdkMetadata = { deviceId: null };
|
||||
try {
|
||||
if (!fs.existsSync(metadataPath)) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = JSON.parse(fs.readFileSync(metadataPath, "utf8")) as {
|
||||
deviceId?: unknown;
|
||||
};
|
||||
return {
|
||||
deviceId:
|
||||
typeof parsed.deviceId === "string" && parsed.deviceId.trim() ? parsed.deviceId : null,
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMatrixLegacyCryptoPlans(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): MatrixLegacyCryptoDetection {
|
||||
const warnings: string[] = [];
|
||||
const plans: MatrixLegacyCryptoPlan[] = [];
|
||||
|
||||
const flatPlan = resolveLegacyMatrixFlatStorePlan(params);
|
||||
if (flatPlan) {
|
||||
if ("warning" in flatPlan) {
|
||||
warnings.push(flatPlan.warning);
|
||||
} else {
|
||||
plans.push(flatPlan);
|
||||
}
|
||||
}
|
||||
|
||||
for (const accountId of resolveMatrixAccountIds(params.cfg)) {
|
||||
const target = resolveMatrixMigrationAccountTarget({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
accountId,
|
||||
});
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
const legacyCryptoPath = path.join(target.rootDir, "crypto");
|
||||
if (!fs.existsSync(legacyCryptoPath)) {
|
||||
continue;
|
||||
}
|
||||
const detectedStore = detectLegacyBotSdkCryptoStore(legacyCryptoPath);
|
||||
if (detectedStore.warning) {
|
||||
warnings.push(detectedStore.warning);
|
||||
continue;
|
||||
}
|
||||
if (!detectedStore.detected) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
plans.some(
|
||||
(plan) =>
|
||||
plan.accountId === accountId &&
|
||||
path.resolve(plan.legacyCryptoPath) === path.resolve(legacyCryptoPath),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const metadata = loadLegacyBotSdkMetadata(legacyCryptoPath);
|
||||
plans.push({
|
||||
accountId: target.accountId,
|
||||
rootDir: target.rootDir,
|
||||
recoveryKeyPath: path.join(target.rootDir, "recovery-key.json"),
|
||||
statePath: path.join(target.rootDir, "legacy-crypto-migration.json"),
|
||||
legacyCryptoPath,
|
||||
homeserver: target.homeserver,
|
||||
userId: target.userId,
|
||||
accessToken: target.accessToken,
|
||||
deviceId: metadata.deviceId ?? target.storedDeviceId,
|
||||
});
|
||||
}
|
||||
|
||||
return { plans, warnings };
|
||||
}
|
||||
|
||||
function loadStoredRecoveryKey(filePath: string): MatrixStoredRecoveryKey | null {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8")) as MatrixStoredRecoveryKey;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadLegacyCryptoMigrationState(filePath: string): MatrixLegacyCryptoMigrationState | null {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8")) as MatrixLegacyCryptoMigrationState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function persistLegacyMigrationState(params: {
|
||||
filePath: string;
|
||||
state: MatrixLegacyCryptoMigrationState;
|
||||
writeJsonFileAtomically: typeof writeJsonFileAtomicallyImpl;
|
||||
}): Promise<void> {
|
||||
await params.writeJsonFileAtomically(params.filePath, params.state);
|
||||
}
|
||||
|
||||
export function detectLegacyMatrixCrypto(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): MatrixLegacyCryptoDetection {
|
||||
const detection = resolveMatrixLegacyCryptoPlans({
|
||||
cfg: params.cfg,
|
||||
env: params.env ?? process.env,
|
||||
});
|
||||
if (
|
||||
detection.plans.length > 0 &&
|
||||
!isMatrixLegacyCryptoInspectorAvailable({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
plans: detection.plans,
|
||||
warnings: [...detection.warnings, MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE],
|
||||
};
|
||||
}
|
||||
return detection;
|
||||
}
|
||||
|
||||
export async function autoPrepareLegacyMatrixCrypto(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
log?: { info?: (message: string) => void; warn?: (message: string) => void };
|
||||
deps?: Partial<MatrixLegacyCryptoPrepareDeps>;
|
||||
}): Promise<MatrixLegacyCryptoPreparationResult> {
|
||||
const env = params.env ?? process.env;
|
||||
const detection = params.deps?.inspectLegacyStore
|
||||
? resolveMatrixLegacyCryptoPlans({ cfg: params.cfg, env })
|
||||
: detectLegacyMatrixCrypto({ cfg: params.cfg, env });
|
||||
const warnings = [...detection.warnings];
|
||||
const changes: string[] = [];
|
||||
const writeJsonFileAtomically =
|
||||
params.deps?.writeJsonFileAtomically ?? writeJsonFileAtomicallyImpl;
|
||||
if (detection.plans.length === 0) {
|
||||
if (warnings.length > 0) {
|
||||
params.log?.warn?.(
|
||||
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
migrated: false,
|
||||
changes,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
let inspectLegacyStore = params.deps?.inspectLegacyStore;
|
||||
if (!inspectLegacyStore) {
|
||||
try {
|
||||
inspectLegacyStore = await loadMatrixLegacyCryptoInspector({
|
||||
cfg: params.cfg,
|
||||
env,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (!warnings.includes(message)) {
|
||||
warnings.push(message);
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
params.log?.warn?.(
|
||||
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
migrated: false,
|
||||
changes,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!inspectLegacyStore) {
|
||||
return {
|
||||
migrated: false,
|
||||
changes,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
for (const plan of detection.plans) {
|
||||
const existingState = loadLegacyCryptoMigrationState(plan.statePath);
|
||||
if (existingState?.version === 1) {
|
||||
continue;
|
||||
}
|
||||
if (!plan.deviceId) {
|
||||
warnings.push(
|
||||
`Legacy Matrix encrypted state detected at ${plan.legacyCryptoPath}, but no device ID was found for account "${plan.accountId}". ` +
|
||||
`OpenClaw will continue, but old encrypted history cannot be recovered automatically.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let summary: MatrixLegacyCryptoSummary;
|
||||
try {
|
||||
summary = await inspectLegacyStore({
|
||||
cryptoRootDir: plan.legacyCryptoPath,
|
||||
userId: plan.userId,
|
||||
deviceId: plan.deviceId,
|
||||
log: params.log?.info,
|
||||
});
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Failed inspecting legacy Matrix encrypted state for account "${plan.accountId}" (${plan.legacyCryptoPath}): ${String(err)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let decryptionKeyImported = false;
|
||||
if (summary.decryptionKeyBase64) {
|
||||
const existingRecoveryKey = loadStoredRecoveryKey(plan.recoveryKeyPath);
|
||||
if (
|
||||
existingRecoveryKey?.privateKeyBase64 &&
|
||||
existingRecoveryKey.privateKeyBase64 !== summary.decryptionKeyBase64
|
||||
) {
|
||||
warnings.push(
|
||||
`Legacy Matrix backup key was found for account "${plan.accountId}", but ${plan.recoveryKeyPath} already contains a different recovery key. Leaving the existing file unchanged.`,
|
||||
);
|
||||
} else if (!existingRecoveryKey?.privateKeyBase64) {
|
||||
const payload: MatrixStoredRecoveryKey = {
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
keyId: null,
|
||||
privateKeyBase64: summary.decryptionKeyBase64,
|
||||
};
|
||||
try {
|
||||
await writeJsonFileAtomically(plan.recoveryKeyPath, payload);
|
||||
changes.push(
|
||||
`Imported Matrix legacy backup key for account "${plan.accountId}": ${plan.recoveryKeyPath}`,
|
||||
);
|
||||
decryptionKeyImported = true;
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Failed writing Matrix recovery key for account "${plan.accountId}" (${plan.recoveryKeyPath}): ${String(err)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
decryptionKeyImported = true;
|
||||
}
|
||||
}
|
||||
|
||||
const localOnlyKeys =
|
||||
summary.roomKeyCounts && summary.roomKeyCounts.total > summary.roomKeyCounts.backedUp
|
||||
? summary.roomKeyCounts.total - summary.roomKeyCounts.backedUp
|
||||
: 0;
|
||||
if (localOnlyKeys > 0) {
|
||||
warnings.push(
|
||||
`Legacy Matrix encrypted state for account "${plan.accountId}" contains ${localOnlyKeys} room key(s) that were never backed up. ` +
|
||||
"Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.",
|
||||
);
|
||||
}
|
||||
if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.backedUp ?? 0) > 0) {
|
||||
warnings.push(
|
||||
`Legacy Matrix encrypted state for account "${plan.accountId}" has backed-up room keys, but no local backup decryption key was found. ` +
|
||||
`Ask the operator to run "openclaw matrix verify backup restore --recovery-key <key>" after upgrade if they have the recovery key.`,
|
||||
);
|
||||
}
|
||||
if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.total ?? 0) > 0) {
|
||||
warnings.push(
|
||||
`Legacy Matrix encrypted state for account "${plan.accountId}" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.`,
|
||||
);
|
||||
}
|
||||
// If recovery-key persistence failed, leave the migration state absent so the next startup can retry.
|
||||
if (
|
||||
summary.decryptionKeyBase64 &&
|
||||
!decryptionKeyImported &&
|
||||
!loadStoredRecoveryKey(plan.recoveryKeyPath)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const state: MatrixLegacyCryptoMigrationState = {
|
||||
version: 1,
|
||||
source: "matrix-bot-sdk-rust",
|
||||
accountId: plan.accountId,
|
||||
deviceId: summary.deviceId,
|
||||
roomKeyCounts: summary.roomKeyCounts,
|
||||
backupVersion: summary.backupVersion,
|
||||
decryptionKeyImported,
|
||||
restoreStatus: decryptionKeyImported ? "pending" : "manual-action-required",
|
||||
detectedAt: new Date().toISOString(),
|
||||
lastError: null,
|
||||
};
|
||||
try {
|
||||
await persistLegacyMigrationState({
|
||||
filePath: plan.statePath,
|
||||
state,
|
||||
writeJsonFileAtomically,
|
||||
});
|
||||
changes.push(
|
||||
`Prepared Matrix legacy encrypted-state migration for account "${plan.accountId}": ${plan.statePath}`,
|
||||
);
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Failed writing Matrix legacy encrypted-state migration record for account "${plan.accountId}" (${plan.statePath}): ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.length > 0) {
|
||||
params.log?.info?.(
|
||||
`matrix: prepared encrypted-state upgrade.\n${changes.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
params.log?.warn?.(
|
||||
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
migrated: changes.length > 0,
|
||||
changes,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./matrix-legacy-state.js";
|
||||
|
||||
function writeFile(filePath: string, value: string) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, value, "utf-8");
|
||||
}
|
||||
|
||||
describe("matrix legacy state migration", () => {
|
||||
it("migrates the flat legacy Matrix store into account-scoped storage", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
|
||||
writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const detection = detectLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(detection && "warning" in detection).toBe(false);
|
||||
if (!detection || "warning" in detection) {
|
||||
throw new Error("expected a migratable Matrix legacy state plan");
|
||||
}
|
||||
|
||||
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(fs.existsSync(path.join(stateDir, "matrix", "bot-storage.json"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(stateDir, "matrix", "crypto"))).toBe(false);
|
||||
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
|
||||
expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses cached Matrix credentials when the config no longer stores an access token", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
|
||||
writeFile(
|
||||
path.join(stateDir, "credentials", "matrix", "credentials.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-from-cache",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const detection = detectLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(detection && "warning" in detection).toBe(false);
|
||||
if (!detection || "warning" in detection) {
|
||||
throw new Error("expected cached credentials to make Matrix migration resolvable");
|
||||
}
|
||||
|
||||
expect(detection.targetRootDir).toContain("matrix.example.org__bot_example.org");
|
||||
|
||||
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("records which account receives a flat legacy store when multiple Matrix accounts exist", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@work-bot:example.org",
|
||||
accessToken: "tok-work",
|
||||
},
|
||||
alerts: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@alerts-bot:example.org",
|
||||
accessToken: "tok-alerts",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const detection = detectLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(detection && "warning" in detection).toBe(false);
|
||||
if (!detection || "warning" in detection) {
|
||||
throw new Error("expected a migratable Matrix legacy state plan");
|
||||
}
|
||||
|
||||
expect(detection.accountId).toBe("work");
|
||||
expect(detection.selectionNote).toContain('account "work"');
|
||||
});
|
||||
});
|
||||
|
||||
it("requires channels.matrix.defaultAccount before migrating a flat store into one of multiple accounts", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
work: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@work-bot:example.org",
|
||||
accessToken: "tok-work",
|
||||
},
|
||||
alerts: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@alerts-bot:example.org",
|
||||
accessToken: "tok-alerts",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const detection = detectLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(detection && "warning" in detection).toBe(true);
|
||||
if (!detection || !("warning" in detection)) {
|
||||
throw new Error("expected a warning-only Matrix legacy state result");
|
||||
}
|
||||
expect(detection.warning).toContain("channels.matrix.defaultAccount is not set");
|
||||
});
|
||||
});
|
||||
|
||||
it("uses scoped Matrix env vars when resolving a flat-store migration target", async () => {
|
||||
await withTempHome(
|
||||
async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
|
||||
writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const detection = detectLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(detection && "warning" in detection).toBe(false);
|
||||
if (!detection || "warning" in detection) {
|
||||
throw new Error("expected scoped Matrix env vars to resolve a legacy state plan");
|
||||
}
|
||||
|
||||
expect(detection.accountId).toBe("ops");
|
||||
expect(detection.targetRootDir).toContain("matrix.example.org__ops-bot_example.org");
|
||||
|
||||
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
|
||||
expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true);
|
||||
},
|
||||
{
|
||||
env: {
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_USER_ID: "@ops-bot:example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "tok-ops-env",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("migrates flat legacy Matrix state into the only configured non-default account", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
|
||||
writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto");
|
||||
writeFile(
|
||||
path.join(stateDir, "credentials", "matrix", "credentials-ops.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops-bot:example.org",
|
||||
accessToken: "tok-ops",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops-bot:example.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const detection = detectLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(detection && "warning" in detection).toBe(false);
|
||||
if (!detection || "warning" in detection) {
|
||||
throw new Error("expected a migratable Matrix legacy state plan");
|
||||
}
|
||||
|
||||
expect(detection.accountId).toBe("ops");
|
||||
|
||||
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
|
||||
expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { resolveMatrixLegacyFlatStoragePaths } from "./matrix-config-helpers.js";
|
||||
import { resolveLegacyMatrixFlatStoreTarget } from "./matrix-migration-config.js";
|
||||
|
||||
export type MatrixLegacyStateMigrationResult = {
|
||||
migrated: boolean;
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
type MatrixLegacyStatePlan = {
|
||||
accountId: string;
|
||||
legacyStoragePath: string;
|
||||
legacyCryptoPath: string;
|
||||
targetRootDir: string;
|
||||
targetStoragePath: string;
|
||||
targetCryptoPath: string;
|
||||
selectionNote?: string;
|
||||
};
|
||||
|
||||
function resolveLegacyMatrixPaths(env: NodeJS.ProcessEnv): {
|
||||
rootDir: string;
|
||||
storagePath: string;
|
||||
cryptoPath: string;
|
||||
} {
|
||||
const stateDir = resolveStateDir(env, os.homedir);
|
||||
return resolveMatrixLegacyFlatStoragePaths(stateDir);
|
||||
}
|
||||
|
||||
function resolveMatrixMigrationPlan(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): MatrixLegacyStatePlan | { warning: string } | null {
|
||||
const legacy = resolveLegacyMatrixPaths(params.env);
|
||||
if (!fs.existsSync(legacy.storagePath) && !fs.existsSync(legacy.cryptoPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = resolveLegacyMatrixFlatStoreTarget({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
detectedPath: legacy.rootDir,
|
||||
detectedKind: "state",
|
||||
});
|
||||
if ("warning" in target) {
|
||||
return target;
|
||||
}
|
||||
|
||||
return {
|
||||
accountId: target.accountId,
|
||||
legacyStoragePath: legacy.storagePath,
|
||||
legacyCryptoPath: legacy.cryptoPath,
|
||||
targetRootDir: target.rootDir,
|
||||
targetStoragePath: path.join(target.rootDir, "bot-storage.json"),
|
||||
targetCryptoPath: path.join(target.rootDir, "crypto"),
|
||||
selectionNote: target.selectionNote,
|
||||
};
|
||||
}
|
||||
|
||||
export function detectLegacyMatrixState(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): MatrixLegacyStatePlan | { warning: string } | null {
|
||||
return resolveMatrixMigrationPlan({
|
||||
cfg: params.cfg,
|
||||
env: params.env ?? process.env,
|
||||
});
|
||||
}
|
||||
|
||||
function moveLegacyPath(params: {
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
label: string;
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
}): void {
|
||||
if (!fs.existsSync(params.sourcePath)) {
|
||||
return;
|
||||
}
|
||||
if (fs.existsSync(params.targetPath)) {
|
||||
params.warnings.push(
|
||||
`Matrix legacy ${params.label} not migrated because the target already exists (${params.targetPath}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(params.targetPath), { recursive: true });
|
||||
fs.renameSync(params.sourcePath, params.targetPath);
|
||||
params.changes.push(
|
||||
`Migrated Matrix legacy ${params.label}: ${params.sourcePath} -> ${params.targetPath}`,
|
||||
);
|
||||
} catch (err) {
|
||||
params.warnings.push(
|
||||
`Failed migrating Matrix legacy ${params.label} (${params.sourcePath} -> ${params.targetPath}): ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function autoMigrateLegacyMatrixState(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
log?: { info?: (message: string) => void; warn?: (message: string) => void };
|
||||
}): Promise<MatrixLegacyStateMigrationResult> {
|
||||
const env = params.env ?? process.env;
|
||||
const detection = detectLegacyMatrixState({ cfg: params.cfg, env });
|
||||
if (!detection) {
|
||||
return { migrated: false, changes: [], warnings: [] };
|
||||
}
|
||||
if ("warning" in detection) {
|
||||
params.log?.warn?.(`matrix: ${detection.warning}`);
|
||||
return { migrated: false, changes: [], warnings: [detection.warning] };
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
moveLegacyPath({
|
||||
sourcePath: detection.legacyStoragePath,
|
||||
targetPath: detection.targetStoragePath,
|
||||
label: "sync store",
|
||||
changes,
|
||||
warnings,
|
||||
});
|
||||
moveLegacyPath({
|
||||
sourcePath: detection.legacyCryptoPath,
|
||||
targetPath: detection.targetCryptoPath,
|
||||
label: "crypto store",
|
||||
changes,
|
||||
warnings,
|
||||
});
|
||||
|
||||
if (changes.length > 0) {
|
||||
const details = [
|
||||
...changes.map((entry) => `- ${entry}`),
|
||||
...(detection.selectionNote ? [`- ${detection.selectionNote}`] : []),
|
||||
"- No user action required.",
|
||||
];
|
||||
params.log?.info?.(
|
||||
`matrix: plugin upgraded in place for account "${detection.accountId}".\n${details.join("\n")}`,
|
||||
);
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
params.log?.warn?.(
|
||||
`matrix: legacy state migration warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
migrated: changes.length > 0,
|
||||
changes,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||
import {
|
||||
findMatrixAccountEntry,
|
||||
getMatrixScopedEnvVarNames,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixAccountStorageRoot,
|
||||
resolveMatrixChannelConfig,
|
||||
resolveMatrixCredentialsPath,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "./matrix-config-helpers.js";
|
||||
|
||||
export type MatrixStoredCredentials = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
deviceId?: string;
|
||||
};
|
||||
|
||||
export type MatrixMigrationAccountTarget = {
|
||||
accountId: string;
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
rootDir: string;
|
||||
storedDeviceId: string | null;
|
||||
};
|
||||
|
||||
export type MatrixLegacyFlatStoreTarget = MatrixMigrationAccountTarget & {
|
||||
selectionNote?: string;
|
||||
};
|
||||
|
||||
type MatrixLegacyFlatStoreKind = "state" | "encrypted state";
|
||||
|
||||
type MatrixResolvedStringField =
|
||||
| "homeserver"
|
||||
| "userId"
|
||||
| "accessToken"
|
||||
| "password"
|
||||
| "deviceId"
|
||||
| "deviceName";
|
||||
|
||||
type MatrixResolvedStringValues = Record<MatrixResolvedStringField, string>;
|
||||
|
||||
type MatrixStringSourceMap = Partial<Record<MatrixResolvedStringField, string>>;
|
||||
|
||||
const MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS = new Set<MatrixResolvedStringField>([
|
||||
"userId",
|
||||
"accessToken",
|
||||
"password",
|
||||
"deviceId",
|
||||
]);
|
||||
|
||||
function clean(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function resolveMatrixStringSourceValue(value: string | undefined): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function shouldAllowBaseAuthFallback(accountId: string, field: MatrixResolvedStringField): boolean {
|
||||
return (
|
||||
normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID ||
|
||||
!MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS.has(field)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatrixAccountStringValues(params: {
|
||||
accountId: string;
|
||||
account?: MatrixStringSourceMap;
|
||||
scopedEnv?: MatrixStringSourceMap;
|
||||
channel?: MatrixStringSourceMap;
|
||||
globalEnv?: MatrixStringSourceMap;
|
||||
}): MatrixResolvedStringValues {
|
||||
const fields: MatrixResolvedStringField[] = [
|
||||
"homeserver",
|
||||
"userId",
|
||||
"accessToken",
|
||||
"password",
|
||||
"deviceId",
|
||||
"deviceName",
|
||||
];
|
||||
const resolved = {} as MatrixResolvedStringValues;
|
||||
|
||||
for (const field of fields) {
|
||||
resolved[field] =
|
||||
resolveMatrixStringSourceValue(params.account?.[field]) ||
|
||||
resolveMatrixStringSourceValue(params.scopedEnv?.[field]) ||
|
||||
(shouldAllowBaseAuthFallback(params.accountId, field)
|
||||
? resolveMatrixStringSourceValue(params.channel?.[field]) ||
|
||||
resolveMatrixStringSourceValue(params.globalEnv?.[field])
|
||||
: "");
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function resolveScopedMatrixEnvConfig(
|
||||
accountId: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
} {
|
||||
const keys = getMatrixScopedEnvVarNames(accountId);
|
||||
return {
|
||||
homeserver: clean(env[keys.homeserver]),
|
||||
userId: clean(env[keys.userId]),
|
||||
accessToken: clean(env[keys.accessToken]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
} {
|
||||
return {
|
||||
homeserver: clean(env.MATRIX_HOMESERVER),
|
||||
userId: clean(env.MATRIX_USER_ID),
|
||||
accessToken: clean(env.MATRIX_ACCESS_TOKEN),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMatrixAccountConfigEntry(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): Record<string, unknown> | null {
|
||||
return findMatrixAccountEntry(cfg, accountId);
|
||||
}
|
||||
|
||||
function resolveMatrixFlatStoreSelectionNote(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): string | undefined {
|
||||
if (resolveConfiguredMatrixAccountIds(cfg).length <= 1) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
`Legacy Matrix flat store uses one shared on-disk state, so it will be migrated into ` +
|
||||
`account "${accountId}".`
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMatrixMigrationConfigFields(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
accountId: string;
|
||||
}): {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
} {
|
||||
const channel = resolveMatrixChannelConfig(params.cfg);
|
||||
const account = resolveMatrixAccountConfigEntry(params.cfg, params.accountId);
|
||||
const scopedEnv = resolveScopedMatrixEnvConfig(params.accountId, params.env);
|
||||
const globalEnv = resolveGlobalMatrixEnvConfig(params.env);
|
||||
const normalizedAccountId = normalizeAccountId(params.accountId);
|
||||
const resolvedStrings = resolveMatrixAccountStringValues({
|
||||
accountId: normalizedAccountId,
|
||||
account: {
|
||||
homeserver: clean(account?.homeserver),
|
||||
userId: clean(account?.userId),
|
||||
accessToken: clean(account?.accessToken),
|
||||
},
|
||||
scopedEnv,
|
||||
channel: {
|
||||
homeserver: clean(channel?.homeserver),
|
||||
userId: clean(channel?.userId),
|
||||
accessToken: clean(channel?.accessToken),
|
||||
},
|
||||
globalEnv,
|
||||
});
|
||||
|
||||
return {
|
||||
homeserver: resolvedStrings.homeserver,
|
||||
userId: resolvedStrings.userId,
|
||||
accessToken: resolvedStrings.accessToken,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadStoredMatrixCredentials(
|
||||
env: NodeJS.ProcessEnv,
|
||||
accountId: string,
|
||||
): MatrixStoredCredentials | null {
|
||||
const stateDir = resolveStateDir(env, os.homedir);
|
||||
const credentialsPath = resolveMatrixCredentialsPath({
|
||||
stateDir,
|
||||
accountId: normalizeAccountId(accountId),
|
||||
});
|
||||
try {
|
||||
if (!fs.existsSync(credentialsPath)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(credentialsPath, "utf8"),
|
||||
) as Partial<MatrixStoredCredentials>;
|
||||
if (
|
||||
typeof parsed.homeserver !== "string" ||
|
||||
typeof parsed.userId !== "string" ||
|
||||
typeof parsed.accessToken !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
homeserver: parsed.homeserver,
|
||||
userId: parsed.userId,
|
||||
accessToken: parsed.accessToken,
|
||||
deviceId: typeof parsed.deviceId === "string" ? parsed.deviceId : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function credentialsMatchResolvedIdentity(
|
||||
stored: MatrixStoredCredentials | null,
|
||||
identity: {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
},
|
||||
): stored is MatrixStoredCredentials {
|
||||
if (!stored || !identity.homeserver) {
|
||||
return false;
|
||||
}
|
||||
if (!identity.userId) {
|
||||
if (!identity.accessToken) {
|
||||
return false;
|
||||
}
|
||||
return stored.homeserver === identity.homeserver && stored.accessToken === identity.accessToken;
|
||||
}
|
||||
return stored.homeserver === identity.homeserver && stored.userId === identity.userId;
|
||||
}
|
||||
|
||||
export function resolveMatrixMigrationAccountTarget(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
accountId: string;
|
||||
}): MatrixMigrationAccountTarget | null {
|
||||
const stored = loadStoredMatrixCredentials(params.env, params.accountId);
|
||||
const resolved = resolveMatrixMigrationConfigFields(params);
|
||||
const matchingStored = credentialsMatchResolvedIdentity(stored, {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: resolved.userId,
|
||||
accessToken: resolved.accessToken,
|
||||
})
|
||||
? stored
|
||||
: null;
|
||||
const homeserver = resolved.homeserver;
|
||||
const userId = resolved.userId || matchingStored?.userId || "";
|
||||
const accessToken = resolved.accessToken || matchingStored?.accessToken || "";
|
||||
if (!homeserver || !userId || !accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stateDir = resolveStateDir(params.env, os.homedir);
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
|
||||
return {
|
||||
accountId: params.accountId,
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken,
|
||||
rootDir,
|
||||
storedDeviceId: matchingStored?.deviceId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveLegacyMatrixFlatStoreTarget(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
detectedPath: string;
|
||||
detectedKind: MatrixLegacyFlatStoreKind;
|
||||
}): MatrixLegacyFlatStoreTarget | { warning: string } {
|
||||
const channel = resolveMatrixChannelConfig(params.cfg);
|
||||
if (!channel) {
|
||||
return {
|
||||
warning:
|
||||
`Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but channels.matrix is not configured yet. ` +
|
||||
'Configure Matrix, then rerun "openclaw doctor --fix" or restart the gateway.',
|
||||
};
|
||||
}
|
||||
if (requiresExplicitMatrixDefaultAccount(params.cfg)) {
|
||||
return {
|
||||
warning:
|
||||
`Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set. ` +
|
||||
'Set "channels.matrix.defaultAccount" to the intended target account before rerunning "openclaw doctor --fix" or restarting the gateway.',
|
||||
};
|
||||
}
|
||||
|
||||
const accountId = resolveMatrixDefaultOrOnlyAccountId(params.cfg);
|
||||
const target = resolveMatrixMigrationAccountTarget({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
accountId,
|
||||
});
|
||||
if (!target) {
|
||||
const targetDescription =
|
||||
params.detectedKind === "state"
|
||||
? "the new account-scoped target"
|
||||
: "the account-scoped target";
|
||||
return {
|
||||
warning:
|
||||
`Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but ${targetDescription} could not be resolved yet ` +
|
||||
`(need homeserver, userId, and access token for channels.matrix${accountId === DEFAULT_ACCOUNT_ID ? "" : `.accounts.${accountId}`}). ` +
|
||||
'Start the gateway once with a working Matrix login, or rerun "openclaw doctor --fix" after cached credentials are available.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...target,
|
||||
selectionNote: resolveMatrixFlatStoreSelectionNote(params.cfg, accountId),
|
||||
};
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "./matrix-config-helpers.js";
|
||||
import { detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js";
|
||||
import {
|
||||
hasActionableMatrixMigration,
|
||||
maybeCreateMatrixMigrationSnapshot,
|
||||
resolveMatrixMigrationSnapshotMarkerPath,
|
||||
resolveMatrixMigrationSnapshotOutputDir,
|
||||
} from "./matrix-migration-snapshot.js";
|
||||
|
||||
describe("matrix migration snapshots", () => {
|
||||
it("creates a backup marker after writing a pre-migration snapshot", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
fs.writeFileSync(path.join(home, ".openclaw", "openclaw.json"), "{}\n", "utf8");
|
||||
fs.writeFileSync(path.join(home, ".openclaw", "state.txt"), "state\n", "utf8");
|
||||
|
||||
const result = await maybeCreateMatrixMigrationSnapshot({ trigger: "unit-test" });
|
||||
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.markerPath).toBe(resolveMatrixMigrationSnapshotMarkerPath(process.env));
|
||||
expect(
|
||||
result.archivePath.startsWith(resolveMatrixMigrationSnapshotOutputDir(process.env)),
|
||||
).toBe(true);
|
||||
expect(fs.existsSync(result.archivePath)).toBe(true);
|
||||
|
||||
const marker = JSON.parse(
|
||||
fs.readFileSync(resolveMatrixMigrationSnapshotMarkerPath(process.env), "utf8"),
|
||||
) as {
|
||||
archivePath: string;
|
||||
trigger: string;
|
||||
};
|
||||
expect(marker.archivePath).toBe(result.archivePath);
|
||||
expect(marker.trigger).toBe("unit-test");
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses an existing snapshot marker when the archive still exists", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const archivePath = path.join(home, "Backups", "openclaw-migrations", "snapshot.tar.gz");
|
||||
const markerPath = resolveMatrixMigrationSnapshotMarkerPath(process.env);
|
||||
fs.mkdirSync(path.dirname(archivePath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
|
||||
fs.writeFileSync(archivePath, "archive", "utf8");
|
||||
fs.writeFileSync(
|
||||
markerPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
createdAt: "2026-03-10T18:00:00.000Z",
|
||||
archivePath,
|
||||
trigger: "older-run",
|
||||
includeWorkspace: false,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await maybeCreateMatrixMigrationSnapshot({ trigger: "unit-test" });
|
||||
|
||||
expect(result.created).toBe(false);
|
||||
expect(result.archivePath).toBe(archivePath);
|
||||
});
|
||||
});
|
||||
|
||||
it("recreates the snapshot when the marker exists but the archive is missing", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const markerPath = resolveMatrixMigrationSnapshotMarkerPath(process.env);
|
||||
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
|
||||
fs.mkdirSync(path.join(home, "Backups", "openclaw-migrations"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
markerPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
createdAt: "2026-03-10T18:00:00.000Z",
|
||||
archivePath: path.join(home, "Backups", "openclaw-migrations", "missing.tar.gz"),
|
||||
trigger: "older-run",
|
||||
includeWorkspace: false,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await maybeCreateMatrixMigrationSnapshot({ trigger: "unit-test" });
|
||||
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.archivePath).not.toBe(
|
||||
path.join(home, "Backups", "openclaw-migrations", "missing.tar.gz"),
|
||||
);
|
||||
expect(
|
||||
result.archivePath.startsWith(resolveMatrixMigrationSnapshotOutputDir(process.env)),
|
||||
).toBe(true);
|
||||
expect(fs.existsSync(result.archivePath)).toBe(true);
|
||||
const marker = JSON.parse(fs.readFileSync(markerPath, "utf8")) as { archivePath: string };
|
||||
expect(marker.archivePath).toBe(result.archivePath);
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces backup creation failures without writing a marker", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const invalidOutputPath = path.join(home, "invalid-output");
|
||||
fs.writeFileSync(invalidOutputPath, "occupied\n", "utf8");
|
||||
|
||||
await expect(
|
||||
maybeCreateMatrixMigrationSnapshot({
|
||||
trigger: "unit-test",
|
||||
outputDir: invalidOutputPath,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
expect(fs.existsSync(resolveMatrixMigrationSnapshotMarkerPath(process.env))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat warning-only Matrix migration as actionable", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
fs.mkdirSync(path.join(stateDir, "matrix", "crypto"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(stateDir, "matrix", "bot-storage.json"),
|
||||
'{"legacy":true}',
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(stateDir, "openclaw.json"),
|
||||
JSON.stringify({
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(
|
||||
hasActionableMatrixMigration({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
env: process.env,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("treats resolvable Matrix legacy state as actionable", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
fs.mkdirSync(path.join(stateDir, "matrix"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(stateDir, "matrix", "bot-storage.json"),
|
||||
'{"legacy":true}',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(
|
||||
hasActionableMatrixMigration({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
env: process.env,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("treats legacy Matrix crypto as warning-only until the plugin helper is available", async () => {
|
||||
await withTempHome(
|
||||
async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
fs.mkdirSync(path.join(home, "empty-bundled"), { recursive: true });
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
});
|
||||
fs.mkdirSync(path.join(rootDir, "crypto"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: "DEVICE123" }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
|
||||
const detection = detectLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
});
|
||||
expect(detection.plans).toHaveLength(1);
|
||||
expect(detection.warnings).toContain(
|
||||
"Legacy Matrix encrypted state was detected, but the Matrix plugin helper is unavailable. Install or repair @openclaw/matrix so OpenClaw can inspect the old rust crypto store before upgrading.",
|
||||
);
|
||||
expect(
|
||||
hasActionableMatrixMigration({
|
||||
cfg,
|
||||
env: process.env,
|
||||
}),
|
||||
).toBe(false);
|
||||
},
|
||||
{
|
||||
env: {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "empty-bundled"),
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { writeJsonFileAtomically } from "../plugin-sdk/json-store.js";
|
||||
import { createBackupArchive } from "./backup-create.js";
|
||||
import { resolveRequiredHomeDir } from "./home-dir.js";
|
||||
import { detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js";
|
||||
import { detectLegacyMatrixState } from "./matrix-legacy-state.js";
|
||||
import { isMatrixLegacyCryptoInspectorAvailable } from "./matrix-plugin-helper.js";
|
||||
|
||||
const MATRIX_MIGRATION_SNAPSHOT_DIRNAME = "openclaw-migrations";
|
||||
|
||||
type MatrixMigrationSnapshotMarker = {
|
||||
version: 1;
|
||||
createdAt: string;
|
||||
archivePath: string;
|
||||
trigger: string;
|
||||
includeWorkspace: boolean;
|
||||
};
|
||||
|
||||
export type MatrixMigrationSnapshotResult = {
|
||||
created: boolean;
|
||||
archivePath: string;
|
||||
markerPath: string;
|
||||
};
|
||||
|
||||
function loadSnapshotMarker(filePath: string): MatrixMigrationSnapshotMarker | null {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(filePath, "utf8"),
|
||||
) as Partial<MatrixMigrationSnapshotMarker>;
|
||||
if (
|
||||
parsed.version !== 1 ||
|
||||
typeof parsed.createdAt !== "string" ||
|
||||
typeof parsed.archivePath !== "string" ||
|
||||
typeof parsed.trigger !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
createdAt: parsed.createdAt,
|
||||
archivePath: parsed.archivePath,
|
||||
trigger: parsed.trigger,
|
||||
includeWorkspace: parsed.includeWorkspace === true,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveMatrixMigrationSnapshotMarkerPath(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
const stateDir = resolveStateDir(env, os.homedir);
|
||||
return path.join(stateDir, "matrix", "migration-snapshot.json");
|
||||
}
|
||||
|
||||
export function resolveMatrixMigrationSnapshotOutputDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
const homeDir = resolveRequiredHomeDir(env, os.homedir);
|
||||
return path.join(homeDir, "Backups", MATRIX_MIGRATION_SNAPSHOT_DIRNAME);
|
||||
}
|
||||
|
||||
export function hasPendingMatrixMigration(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): boolean {
|
||||
const env = params.env ?? process.env;
|
||||
const legacyState = detectLegacyMatrixState({ cfg: params.cfg, env });
|
||||
if (legacyState) {
|
||||
return true;
|
||||
}
|
||||
const legacyCrypto = detectLegacyMatrixCrypto({ cfg: params.cfg, env });
|
||||
return legacyCrypto.plans.length > 0 || legacyCrypto.warnings.length > 0;
|
||||
}
|
||||
|
||||
export function hasActionableMatrixMigration(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): boolean {
|
||||
const env = params.env ?? process.env;
|
||||
const legacyState = detectLegacyMatrixState({ cfg: params.cfg, env });
|
||||
if (legacyState && !("warning" in legacyState)) {
|
||||
return true;
|
||||
}
|
||||
const legacyCrypto = detectLegacyMatrixCrypto({ cfg: params.cfg, env });
|
||||
return (
|
||||
legacyCrypto.plans.length > 0 &&
|
||||
isMatrixLegacyCryptoInspectorAvailable({
|
||||
cfg: params.cfg,
|
||||
env,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function maybeCreateMatrixMigrationSnapshot(params: {
|
||||
trigger: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
outputDir?: string;
|
||||
log?: { info?: (message: string) => void; warn?: (message: string) => void };
|
||||
}): Promise<MatrixMigrationSnapshotResult> {
|
||||
const env = params.env ?? process.env;
|
||||
const markerPath = resolveMatrixMigrationSnapshotMarkerPath(env);
|
||||
const existingMarker = loadSnapshotMarker(markerPath);
|
||||
if (existingMarker?.archivePath && fs.existsSync(existingMarker.archivePath)) {
|
||||
params.log?.info?.(
|
||||
`matrix: reusing existing pre-migration backup snapshot: ${existingMarker.archivePath}`,
|
||||
);
|
||||
return {
|
||||
created: false,
|
||||
archivePath: existingMarker.archivePath,
|
||||
markerPath,
|
||||
};
|
||||
}
|
||||
if (existingMarker?.archivePath && !fs.existsSync(existingMarker.archivePath)) {
|
||||
params.log?.warn?.(
|
||||
`matrix: previous migration snapshot is missing (${existingMarker.archivePath}); creating a replacement backup before continuing`,
|
||||
);
|
||||
}
|
||||
|
||||
const snapshot = await createBackupArchive({
|
||||
output: (() => {
|
||||
const outputDir = params.outputDir ?? resolveMatrixMigrationSnapshotOutputDir(env);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
return outputDir;
|
||||
})(),
|
||||
includeWorkspace: false,
|
||||
});
|
||||
|
||||
const marker: MatrixMigrationSnapshotMarker = {
|
||||
version: 1,
|
||||
createdAt: snapshot.createdAt,
|
||||
archivePath: snapshot.archivePath,
|
||||
trigger: params.trigger,
|
||||
includeWorkspace: snapshot.includeWorkspace,
|
||||
};
|
||||
await writeJsonFileAtomically(markerPath, marker);
|
||||
params.log?.info?.(`matrix: created pre-migration backup snapshot: ${snapshot.archivePath}`);
|
||||
return {
|
||||
created: true,
|
||||
archivePath: snapshot.archivePath,
|
||||
markerPath,
|
||||
};
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
isMatrixLegacyCryptoInspectorAvailable,
|
||||
loadMatrixLegacyCryptoInspector,
|
||||
} from "./matrix-plugin-helper.js";
|
||||
import {
|
||||
MATRIX_DEFAULT_DEVICE_ID,
|
||||
MATRIX_DEFAULT_USER_ID,
|
||||
matrixHelperEnv,
|
||||
writeMatrixPluginFixture,
|
||||
writeMatrixPluginManifest,
|
||||
} from "./matrix.test-helpers.js";
|
||||
|
||||
vi.unmock("../version.js");
|
||||
|
||||
async function expectLoadedInspector(params: {
|
||||
cfg: OpenClawConfig | Record<string, never>;
|
||||
env: NodeJS.ProcessEnv;
|
||||
expected: {
|
||||
deviceId: string;
|
||||
roomKeyCounts: { total: number; backedUp: number } | null;
|
||||
backupVersion: string | null;
|
||||
decryptionKeyBase64: string | null;
|
||||
};
|
||||
}) {
|
||||
expect(isMatrixLegacyCryptoInspectorAvailable({ cfg: params.cfg, env: params.env })).toBe(true);
|
||||
const inspectLegacyStore = await loadMatrixLegacyCryptoInspector({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
});
|
||||
|
||||
await expect(
|
||||
inspectLegacyStore({
|
||||
cryptoRootDir: "/tmp/legacy",
|
||||
userId: MATRIX_DEFAULT_USER_ID,
|
||||
deviceId: MATRIX_DEFAULT_DEVICE_ID,
|
||||
}),
|
||||
).resolves.toEqual(params.expected);
|
||||
}
|
||||
|
||||
describe("matrix plugin helper resolution", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("loads the legacy crypto inspector from the bundled matrix plugin", async () => {
|
||||
await withTempHome(
|
||||
async (home) => {
|
||||
const bundledRoot = path.join(home, "bundled", "matrix");
|
||||
writeMatrixPluginFixture(
|
||||
bundledRoot,
|
||||
[
|
||||
"export async function inspectLegacyMatrixCryptoStore() {",
|
||||
' return { deviceId: "BUNDLED", roomKeyCounts: { total: 7, backedUp: 6 }, backupVersion: "1", decryptionKeyBase64: "YWJjZA==" };',
|
||||
"}",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const cfg = {} as const;
|
||||
|
||||
await expectLoadedInspector({
|
||||
cfg,
|
||||
env: process.env,
|
||||
expected: {
|
||||
deviceId: "BUNDLED",
|
||||
roomKeyCounts: { total: 7, backedUp: 6 },
|
||||
backupVersion: "1",
|
||||
decryptionKeyBase64: "YWJjZA==",
|
||||
},
|
||||
});
|
||||
},
|
||||
{ env: matrixHelperEnv },
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers configured plugin load paths over bundled matrix plugins", async () => {
|
||||
await withTempHome(
|
||||
async (home) => {
|
||||
const bundledRoot = path.join(home, "bundled", "matrix");
|
||||
const customRoot = path.join(home, "plugins", "matrix-local");
|
||||
writeMatrixPluginFixture(
|
||||
bundledRoot,
|
||||
[
|
||||
"export async function inspectLegacyMatrixCryptoStore() {",
|
||||
' return { deviceId: "BUNDLED", roomKeyCounts: null, backupVersion: null, decryptionKeyBase64: null };',
|
||||
"}",
|
||||
].join("\n"),
|
||||
);
|
||||
writeMatrixPluginFixture(
|
||||
customRoot,
|
||||
[
|
||||
"export default async function inspectLegacyMatrixCryptoStore() {",
|
||||
' return { deviceId: "CONFIG", roomKeyCounts: null, backupVersion: null, decryptionKeyBase64: null };',
|
||||
"}",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
plugins: {
|
||||
load: {
|
||||
paths: [customRoot],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expectLoadedInspector({
|
||||
cfg,
|
||||
env: process.env,
|
||||
expected: {
|
||||
deviceId: "CONFIG",
|
||||
roomKeyCounts: null,
|
||||
backupVersion: null,
|
||||
decryptionKeyBase64: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
{ env: matrixHelperEnv },
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps source-style root helper shims on the Jiti fallback path", async () => {
|
||||
await withTempHome(
|
||||
async (home) => {
|
||||
const customRoot = path.join(home, "plugins", "matrix-local");
|
||||
writeMatrixPluginManifest(customRoot);
|
||||
fs.mkdirSync(path.join(customRoot, "src", "matrix"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(customRoot, "legacy-crypto-inspector.js"),
|
||||
'export { inspectLegacyMatrixCryptoStore } from "./src/matrix/legacy-crypto-inspector.js";\n',
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(customRoot, "src", "matrix", "legacy-crypto-inspector.ts"),
|
||||
[
|
||||
"export async function inspectLegacyMatrixCryptoStore() {",
|
||||
' return { deviceId: "SRCJS", roomKeyCounts: null, backupVersion: null, decryptionKeyBase64: null };',
|
||||
"}",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
plugins: {
|
||||
load: {
|
||||
paths: [customRoot],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expectLoadedInspector({
|
||||
cfg,
|
||||
env: process.env,
|
||||
expected: {
|
||||
deviceId: "SRCJS",
|
||||
roomKeyCounts: null,
|
||||
backupVersion: null,
|
||||
decryptionKeyBase64: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
env: {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "empty-bundled"),
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects helper files that escape the plugin root", async () => {
|
||||
await withTempHome(
|
||||
async (home) => {
|
||||
const customRoot = path.join(home, "plugins", "matrix-local");
|
||||
const outsideRoot = path.join(home, "outside");
|
||||
fs.mkdirSync(customRoot, { recursive: true });
|
||||
fs.mkdirSync(outsideRoot, { recursive: true });
|
||||
writeMatrixPluginManifest(customRoot);
|
||||
const outsideHelper = path.join(outsideRoot, "legacy-crypto-inspector.js");
|
||||
fs.writeFileSync(
|
||||
outsideHelper,
|
||||
'export default async function inspectLegacyMatrixCryptoStore() { return { deviceId: "ESCAPE", roomKeyCounts: null, backupVersion: null, decryptionKeyBase64: null }; }\n',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
fs.symlinkSync(
|
||||
outsideHelper,
|
||||
path.join(customRoot, "legacy-crypto-inspector.js"),
|
||||
process.platform === "win32" ? "file" : undefined,
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
plugins: {
|
||||
load: {
|
||||
paths: [customRoot],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(false);
|
||||
await expect(
|
||||
loadMatrixLegacyCryptoInspector({
|
||||
cfg,
|
||||
env: process.env,
|
||||
}),
|
||||
).rejects.toThrow("Matrix plugin helper path is unsafe");
|
||||
},
|
||||
{
|
||||
env: {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "empty-bundled"),
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export const MATRIX_TEST_HOMESERVER = "https://matrix.example.org";
|
||||
export const MATRIX_DEFAULT_USER_ID = "@bot:example.org";
|
||||
export const MATRIX_DEFAULT_ACCESS_TOKEN = "tok-123";
|
||||
export const MATRIX_DEFAULT_DEVICE_ID = "DEVICE123";
|
||||
export const MATRIX_OPS_ACCOUNT_ID = "ops";
|
||||
export const MATRIX_OPS_USER_ID = "@ops-bot:example.org";
|
||||
export const MATRIX_OPS_ACCESS_TOKEN = "tok-ops";
|
||||
export const MATRIX_OPS_DEVICE_ID = "DEVICEOPS";
|
||||
|
||||
export const matrixHelperEnv = {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: (home: string) => path.join(home, "bundled"),
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
|
||||
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1",
|
||||
OPENCLAW_VERSION: undefined,
|
||||
VITEST: "true",
|
||||
} as const;
|
||||
|
||||
export function writeFile(filePath: string, value: string) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, value, "utf8");
|
||||
}
|
||||
|
||||
export function writeMatrixPluginManifest(rootDir: string): void {
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "matrix",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(rootDir, "index.js"), "export default {};\n", "utf8");
|
||||
}
|
||||
|
||||
export function writeMatrixPluginFixture(rootDir: string, helperBody?: string): void {
|
||||
writeMatrixPluginManifest(rootDir);
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "legacy-crypto-inspector.js"),
|
||||
helperBody ??
|
||||
[
|
||||
"export async function inspectLegacyMatrixCryptoStore() {",
|
||||
' return { deviceId: "FIXTURE", roomKeyCounts: { total: 1, backedUp: 1 }, backupVersion: "1", decryptionKeyBase64: null };',
|
||||
"}",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
export function writeMatrixCredentials(
|
||||
stateDir: string,
|
||||
params?: {
|
||||
accountId?: string;
|
||||
homeserver?: string;
|
||||
userId?: string;
|
||||
accessToken?: string;
|
||||
deviceId?: string;
|
||||
},
|
||||
) {
|
||||
const accountId = params?.accountId ?? MATRIX_OPS_ACCOUNT_ID;
|
||||
writeFile(
|
||||
path.join(stateDir, "credentials", "matrix", `credentials-${accountId}.json`),
|
||||
JSON.stringify(
|
||||
{
|
||||
homeserver: params?.homeserver ?? MATRIX_TEST_HOMESERVER,
|
||||
userId: params?.userId ?? MATRIX_OPS_USER_ID,
|
||||
accessToken: params?.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
|
||||
deviceId: params?.deviceId ?? MATRIX_OPS_DEVICE_ID,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createIMessageTestPlugin } from "../../../test/helpers/channels/imessage-test-plugin.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { createHookRunner } from "../../plugins/hooks.js";
|
||||
import { addTestHook } from "../../plugins/hooks.test-helpers.js";
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
} from "../../plugins/runtime.js";
|
||||
import type { PluginHookRegistration } from "../../plugins/types.js";
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
|
||||
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js";
|
||||
import {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTelegramRetryRunner } from "./retry-policy.js";
|
||||
import { createChannelApiRetryRunner } from "./retry-policy.js";
|
||||
|
||||
const ZERO_DELAY_RETRY = { attempts: 3, minDelayMs: 0, maxDelayMs: 0, jitter: 0 };
|
||||
|
||||
async function runRetryCase(params: {
|
||||
runnerOptions: Parameters<typeof createTelegramRetryRunner>[0];
|
||||
runnerOptions: Parameters<typeof createChannelApiRetryRunner>[0];
|
||||
fnSteps: Array<{ type: "reject" | "resolve"; value: unknown }>;
|
||||
expectedCalls: number;
|
||||
expectedValue?: unknown;
|
||||
expectedError?: string;
|
||||
}): Promise<void> {
|
||||
vi.useFakeTimers();
|
||||
const runner = createTelegramRetryRunner(params.runnerOptions);
|
||||
const runner = createChannelApiRetryRunner(params.runnerOptions);
|
||||
const fn = vi.fn();
|
||||
const allRejects =
|
||||
params.fnSteps.length > 0 && params.fnSteps.every((step) => step.type === "reject");
|
||||
@@ -39,7 +39,7 @@ async function runRetryCase(params: {
|
||||
expect(fn).toHaveBeenCalledTimes(params.expectedCalls);
|
||||
}
|
||||
|
||||
describe("createTelegramRetryRunner", () => {
|
||||
describe("createChannelApiRetryRunner", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
@@ -97,16 +97,18 @@ describe("state migrations", () => {
|
||||
expect(detected.sessions.hasLegacy).toBe(true);
|
||||
expect(detected.sessions.legacyKeys).toEqual(["group:123@g.us"]);
|
||||
expect(detected.agentDir.hasLegacy).toBe(true);
|
||||
expect(detected.whatsappAuth.hasLegacy).toBe(true);
|
||||
expect(detected.pairingAllowFrom.hasLegacyTelegram).toBe(true);
|
||||
expect(detected.pairingAllowFrom.copyPlans.map((plan) => plan.targetPath)).toEqual([
|
||||
expect(detected.channelPlans.hasLegacy).toBe(true);
|
||||
expect(detected.channelPlans.plans.map((plan) => plan.targetPath)).toEqual([
|
||||
path.join(stateDir, "credentials", "whatsapp", "default", "creds.json"),
|
||||
path.join(stateDir, "credentials", "whatsapp", "default", "pre-key-1.json"),
|
||||
resolveChannelAllowFromPath("telegram", env, "alpha"),
|
||||
]);
|
||||
expect(detected.preview).toEqual([
|
||||
`- Sessions: ${path.join(stateDir, "sessions")} → ${path.join(stateDir, "agents", "worker-1", "sessions")}`,
|
||||
`- Sessions: canonicalize legacy keys in ${path.join(stateDir, "agents", "worker-1", "sessions", "sessions.json")}`,
|
||||
`- Agent dir: ${path.join(stateDir, "agent")} → ${path.join(stateDir, "agents", "worker-1", "agent")}`,
|
||||
`- WhatsApp auth: ${path.join(stateDir, "credentials")} → ${path.join(stateDir, "credentials", "whatsapp", "default")} (keep oauth.json)`,
|
||||
`- WhatsApp auth creds.json: ${path.join(stateDir, "credentials", "creds.json")} → ${path.join(stateDir, "credentials", "whatsapp", "default", "creds.json")}`,
|
||||
`- WhatsApp auth pre-key-1.json: ${path.join(stateDir, "credentials", "pre-key-1.json")} → ${path.join(stateDir, "credentials", "whatsapp", "default", "pre-key-1.json")}`,
|
||||
`- Telegram pairing allowFrom: ${resolveChannelAllowFromPath("telegram", env)} → ${resolveChannelAllowFromPath("telegram", env, "alpha")}`,
|
||||
]);
|
||||
});
|
||||
@@ -131,8 +133,8 @@ describe("state migrations", () => {
|
||||
"Canonicalized 1 legacy session key(s)",
|
||||
"Moved trace.jsonl → agents/worker-1/sessions",
|
||||
"Moved agent file settings.json → agents/worker-1/agent",
|
||||
"Moved WhatsApp auth creds.json → whatsapp/default",
|
||||
"Moved WhatsApp auth pre-key-1.json → whatsapp/default",
|
||||
`Moved WhatsApp auth creds.json → ${path.join(stateDir, "credentials", "whatsapp", "default", "creds.json")}`,
|
||||
`Moved WhatsApp auth pre-key-1.json → ${path.join(stateDir, "credentials", "whatsapp", "default", "pre-key-1.json")}`,
|
||||
`Copied Telegram pairing allowFrom → ${resolveChannelAllowFromPath("telegram", env, "alpha")}`,
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markdownToWhatsApp } from "./whatsapp.js";
|
||||
|
||||
describe("markdownToWhatsApp", () => {
|
||||
it.each([
|
||||
["converts **bold** to *bold*", "**SOD Blast:**", "*SOD Blast:*"],
|
||||
["converts __bold__ to *bold*", "__important__", "*important*"],
|
||||
["converts ~~strikethrough~~ to ~strikethrough~", "~~deleted~~", "~deleted~"],
|
||||
["leaves single *italic* unchanged (already WhatsApp bold)", "*text*", "*text*"],
|
||||
["leaves _italic_ unchanged (already WhatsApp italic)", "_text_", "_text_"],
|
||||
["preserves inline code", "Use `**not bold**` here", "Use `**not bold**` here"],
|
||||
[
|
||||
"handles mixed formatting",
|
||||
"**bold** and ~~strike~~ and _italic_",
|
||||
"*bold* and ~strike~ and _italic_",
|
||||
],
|
||||
["handles multiple bold segments", "**one** then **two**", "*one* then *two*"],
|
||||
["returns empty string for empty input", "", ""],
|
||||
["returns plain text unchanged", "no formatting here", "no formatting here"],
|
||||
["handles bold inside a sentence", "This is **very** important", "This is *very* important"],
|
||||
] as const)("handles markdown-to-whatsapp conversion: %s", (_name, input, expected) => {
|
||||
expect(markdownToWhatsApp(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("preserves fenced code blocks", () => {
|
||||
const input = "```\nconst x = **bold**;\n```";
|
||||
expect(markdownToWhatsApp(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("preserves code block with formatting inside", () => {
|
||||
const input = "Before ```**bold** and ~~strike~~``` after **real bold**";
|
||||
expect(markdownToWhatsApp(input)).toBe(
|
||||
"Before ```**bold** and ~~strike~~``` after *real bold*",
|
||||
);
|
||||
});
|
||||
});
|
||||
80
src/media/channel-inbound-roots.contract.test.ts
Normal file
80
src/media/channel-inbound-roots.contract.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
|
||||
resolveIMessageAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots,
|
||||
} from "../../test/helpers/channels/channel-media-roots-contract.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
|
||||
describe("channel-inbound-roots contract", () => {
|
||||
function expectResolvedRootsCase(resolve: () => string[], expected: readonly string[]) {
|
||||
expect(resolve()).toEqual(expected);
|
||||
}
|
||||
|
||||
const accountOverrideCfg = {
|
||||
channels: {
|
||||
imessage: {
|
||||
attachmentRoots: ["/Users/*/Library/Messages/Attachments"],
|
||||
remoteAttachmentRoots: ["/Volumes/shared/imessage"],
|
||||
accounts: {
|
||||
work: {
|
||||
attachmentRoots: ["/Users/work/Library/Messages/Attachments"],
|
||||
remoteAttachmentRoots: ["/srv/work/attachments"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
it("resolves configured attachment roots with account overrides", () => {
|
||||
expectResolvedRootsCase(
|
||||
() => resolveIMessageAttachmentRoots({ cfg: accountOverrideCfg, accountId: "work" }),
|
||||
["/Users/work/Library/Messages/Attachments", "/Users/*/Library/Messages/Attachments"],
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves configured remote attachment roots with account overrides", () => {
|
||||
expectResolvedRootsCase(
|
||||
() => resolveIMessageRemoteAttachmentRoots({ cfg: accountOverrideCfg, accountId: "work" }),
|
||||
[
|
||||
"/srv/work/attachments",
|
||||
"/Volumes/shared/imessage",
|
||||
"/Users/work/Library/Messages/Attachments",
|
||||
"/Users/*/Library/Messages/Attachments",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("matches iMessage account ids case-insensitively for attachment roots", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
imessage: {
|
||||
accounts: {
|
||||
Work: {
|
||||
attachmentRoots: ["/Users/work/Library/Messages/Attachments"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expectResolvedRootsCase(
|
||||
() => resolveIMessageAttachmentRoots({ cfg, accountId: "work" }),
|
||||
["/Users/work/Library/Messages/Attachments", ...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS],
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to default iMessage attachment roots", () => {
|
||||
expectResolvedRootsCase(
|
||||
() => resolveIMessageAttachmentRoots({ cfg: {} as OpenClawConfig }),
|
||||
[...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS],
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to default iMessage remote attachment roots", () => {
|
||||
expectResolvedRootsCase(
|
||||
() => resolveIMessageRemoteAttachmentRoots({ cfg: {} as OpenClawConfig }),
|
||||
[...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
|
||||
isInboundPathAllowed,
|
||||
isValidInboundPathRootPattern,
|
||||
mergeInboundPathRoots,
|
||||
resolveIMessageAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots,
|
||||
} from "./inbound-path-policy.js";
|
||||
|
||||
describe("inbound-path-policy", () => {
|
||||
@@ -20,10 +16,6 @@ describe("inbound-path-policy", () => {
|
||||
).toBe(expected);
|
||||
}
|
||||
|
||||
function expectResolvedIMessageRootsCase(resolve: () => string[], expected: readonly string[]) {
|
||||
expect(resolve()).toEqual(expected);
|
||||
}
|
||||
|
||||
function expectMergedInboundPathRootsCase(params: {
|
||||
defaults: string[];
|
||||
additions: string[];
|
||||
@@ -54,21 +46,6 @@ describe("inbound-path-policy", () => {
|
||||
expectInboundPathAllowedCase(filePath, expected);
|
||||
});
|
||||
|
||||
const accountOverrideCfg = {
|
||||
channels: {
|
||||
imessage: {
|
||||
attachmentRoots: ["/Users/*/Library/Messages/Attachments"],
|
||||
remoteAttachmentRoots: ["/Volumes/shared/imessage"],
|
||||
accounts: {
|
||||
work: {
|
||||
attachmentRoots: ["/Users/work/Library/Messages/Attachments"],
|
||||
remoteAttachmentRoots: ["/srv/work/attachments"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "normalizes and de-duplicates merged roots",
|
||||
@@ -82,63 +59,7 @@ describe("inbound-path-policy", () => {
|
||||
expected: ["/Users/*/Library/Messages/Attachments", "/Volumes/relay/attachments"],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "resolves configured attachment roots with account overrides",
|
||||
run: () =>
|
||||
expectResolvedIMessageRootsCase(
|
||||
() => resolveIMessageAttachmentRoots({ cfg: accountOverrideCfg, accountId: "work" }),
|
||||
["/Users/work/Library/Messages/Attachments", "/Users/*/Library/Messages/Attachments"],
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "resolves configured remote attachment roots with account overrides",
|
||||
run: () =>
|
||||
expectResolvedIMessageRootsCase(
|
||||
() =>
|
||||
resolveIMessageRemoteAttachmentRoots({ cfg: accountOverrideCfg, accountId: "work" }),
|
||||
[
|
||||
"/srv/work/attachments",
|
||||
"/Volumes/shared/imessage",
|
||||
"/Users/work/Library/Messages/Attachments",
|
||||
"/Users/*/Library/Messages/Attachments",
|
||||
],
|
||||
),
|
||||
},
|
||||
] as const)("$name", ({ run }) => {
|
||||
run();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "matches iMessage account ids case-insensitively for attachment roots",
|
||||
resolve: () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
imessage: {
|
||||
accounts: {
|
||||
Work: {
|
||||
attachmentRoots: ["/Users/work/Library/Messages/Attachments"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
return resolveIMessageAttachmentRoots({ cfg, accountId: "work" });
|
||||
},
|
||||
expected: ["/Users/work/Library/Messages/Attachments", ...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS],
|
||||
},
|
||||
{
|
||||
name: "falls back to default iMessage attachment roots",
|
||||
resolve: () => resolveIMessageAttachmentRoots({ cfg: {} as OpenClawConfig }),
|
||||
expected: [...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS],
|
||||
},
|
||||
{
|
||||
name: "falls back to default iMessage remote attachment roots",
|
||||
resolve: () => resolveIMessageRemoteAttachmentRoots({ cfg: {} as OpenClawConfig }),
|
||||
expected: [...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS],
|
||||
},
|
||||
] as const)("$name", ({ resolve, expected }) => {
|
||||
expectResolvedIMessageRootsCase(resolve, expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatPairingApproveHint } from "../channels/plugins/helpers.js";
|
||||
import type { GroupPolicy } from "../config/types.base.js";
|
||||
import { createRestrictSendersChannelSecurity } from "./channel-policy.js";
|
||||
import {
|
||||
createDangerousNameMatchingMutableAllowlistWarningCollector,
|
||||
createRestrictSendersChannelSecurity,
|
||||
} from "./channel-policy.js";
|
||||
|
||||
describe("createRestrictSendersChannelSecurity", () => {
|
||||
it("builds dm policy resolution and open-group warnings from one descriptor", async () => {
|
||||
@@ -56,3 +59,50 @@ describe("createRestrictSendersChannelSecurity", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDangerousNameMatchingMutableAllowlistWarningCollector", () => {
|
||||
const collectWarnings = createDangerousNameMatchingMutableAllowlistWarningCollector({
|
||||
channel: "irc",
|
||||
detector: (entry) => !entry.includes("@"),
|
||||
collectLists: (scope) => [
|
||||
{
|
||||
pathLabel: `${scope.prefix}.allowFrom`,
|
||||
list: scope.account.allowFrom,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it("collects mutable entries while dangerous matching is disabled", () => {
|
||||
expect(
|
||||
collectWarnings({
|
||||
cfg: {
|
||||
channels: {
|
||||
irc: {
|
||||
allowFrom: ["charlie"],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("mutable allowlist entry"),
|
||||
expect.stringContaining("channels.irc.allowFrom: charlie"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips scopes that explicitly allow dangerous name matching", () => {
|
||||
expect(
|
||||
collectWarnings({
|
||||
cfg: {
|
||||
channels: {
|
||||
irc: {
|
||||
dangerouslyAllowNameMatching: true,
|
||||
allowFrom: ["charlie"],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isWhatsAppGroupJid,
|
||||
isWhatsAppUserTarget,
|
||||
normalizeWhatsAppTarget,
|
||||
} from "./whatsapp-targets.js";
|
||||
|
||||
describe("plugin-sdk whatsapp-targets", () => {
|
||||
it("normalizes user targets through the public facade", () => {
|
||||
expect(normalizeWhatsAppTarget("1555123@s.whatsapp.net")).toBe("+1555123");
|
||||
expect(normalizeWhatsAppTarget("whatsapp:+1555123")).toBe("+1555123");
|
||||
});
|
||||
|
||||
it("preserves valid group JIDs through the public facade", () => {
|
||||
expect(isWhatsAppGroupJid("120363401234567890@g.us")).toBe(true);
|
||||
expect(normalizeWhatsAppTarget("120363401234567890@g.us")).toBe("120363401234567890@g.us");
|
||||
});
|
||||
|
||||
it("detects WhatsApp user JIDs through the public facade", () => {
|
||||
expect(isWhatsAppUserTarget("41796666864:0@s.whatsapp.net")).toBe(true);
|
||||
expect(isWhatsAppUserTarget("123456789@lid")).toBe(true);
|
||||
expect(isWhatsAppUserTarget("123456789-987654321@g.us")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -700,12 +700,7 @@ describe("plugin-sdk subpath exports", () => {
|
||||
"createTopLevelChannelDmPolicy",
|
||||
"mergeAllowFromEntries",
|
||||
]);
|
||||
expectSourceMentions("setup-tools", [
|
||||
"formatCliCommand",
|
||||
"detectBinary",
|
||||
"installSignalCli",
|
||||
"formatDocsLink",
|
||||
]);
|
||||
expectSourceMentions("setup-tools", ["formatCliCommand", "detectBinary", "formatDocsLink"]);
|
||||
expectSourceMentions("lazy-runtime", ["createLazyRuntimeSurface", "createLazyRuntimeModule"]);
|
||||
expectSourceContract("self-hosted-provider-setup", {
|
||||
mentions: [
|
||||
|
||||
@@ -2,15 +2,15 @@ import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } fr
|
||||
import type {
|
||||
DiscordInteractiveHandlerContext,
|
||||
DiscordInteractiveHandlerRegistration,
|
||||
} from "../../extensions/discord/api.js";
|
||||
} from "../../test/helpers/channels/interactive-contract.js";
|
||||
import type {
|
||||
SlackInteractiveHandlerContext,
|
||||
SlackInteractiveHandlerRegistration,
|
||||
} from "../../extensions/slack/api.js";
|
||||
} from "../../test/helpers/channels/interactive-contract.js";
|
||||
import type {
|
||||
TelegramInteractiveHandlerContext,
|
||||
TelegramInteractiveHandlerRegistration,
|
||||
} from "../../extensions/telegram/api.js";
|
||||
} from "../../test/helpers/channels/interactive-contract.js";
|
||||
import * as conversationBinding from "./conversation-binding.js";
|
||||
import { createInteractiveConversationBindingHelpers } from "./interactive-binding-helpers.js";
|
||||
import {
|
||||
@@ -608,6 +608,16 @@ describe("plugin interactive handlers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves arbitrary plugin-owned channel ids", () => {
|
||||
const result = registerPluginInteractiveHandler("plugin-a", {
|
||||
channel: "msteams",
|
||||
namespace: "codex",
|
||||
handler: async () => ({ handled: true }),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("acknowledges matched Discord interactions before awaiting plugin handlers", async () => {
|
||||
const callOrder: string[] = [];
|
||||
const handler = vi.fn(async () => {
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
import { createWriteStream } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { request } from "node:https";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { extractArchive } from "../infra/archive.js";
|
||||
import { resolveBrewExecutable } from "../infra/brew.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { CONFIG_DIR } from "../utils.js";
|
||||
|
||||
export type ReleaseAsset = {
|
||||
name?: string;
|
||||
browser_download_url?: string;
|
||||
};
|
||||
|
||||
export type NamedAsset = {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
};
|
||||
|
||||
type ReleaseResponse = {
|
||||
tag_name?: string;
|
||||
assets?: ReleaseAsset[];
|
||||
};
|
||||
|
||||
export type SignalInstallResult = {
|
||||
ok: boolean;
|
||||
cliPath?: string;
|
||||
version?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export async function extractSignalCliArchive(
|
||||
archivePath: string,
|
||||
installRoot: string,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
await extractArchive({ archivePath, destDir: installRoot, timeoutMs });
|
||||
}
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export function looksLikeArchive(name: string): boolean {
|
||||
return name.endsWith(".tar.gz") || name.endsWith(".tgz") || name.endsWith(".zip");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a native release asset from the official GitHub releases.
|
||||
*
|
||||
* The official signal-cli releases only publish native (GraalVM) binaries for
|
||||
* x86-64 Linux. On architectures where no native asset is available this
|
||||
* returns `undefined` so the caller can fall back to a different install
|
||||
* strategy (e.g. Homebrew).
|
||||
*/
|
||||
/** @internal Exported for testing. */
|
||||
export function pickAsset(
|
||||
assets: ReleaseAsset[],
|
||||
platform: NodeJS.Platform,
|
||||
arch: string,
|
||||
): NamedAsset | undefined {
|
||||
const withName = assets.filter((asset): asset is NamedAsset =>
|
||||
Boolean(asset.name && asset.browser_download_url),
|
||||
);
|
||||
|
||||
// Archives only, excluding signature files (.asc)
|
||||
const archives = withName.filter((a) => looksLikeArchive(a.name.toLowerCase()));
|
||||
|
||||
const byName = (pattern: RegExp) =>
|
||||
archives.find((asset) => pattern.test(asset.name.toLowerCase()));
|
||||
|
||||
if (platform === "linux") {
|
||||
// The official "Linux-native" asset is an x86-64 GraalVM binary.
|
||||
// On non-x64 architectures it will fail with "Exec format error",
|
||||
// so only select it when the host architecture matches.
|
||||
if (arch === "x64") {
|
||||
return byName(/linux-native/) || byName(/linux/) || archives[0];
|
||||
}
|
||||
// No native release for this arch — caller should fall back.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (platform === "darwin") {
|
||||
return byName(/macos|osx|darwin/) || archives[0];
|
||||
}
|
||||
|
||||
if (platform === "win32") {
|
||||
return byName(/windows|win/) || archives[0];
|
||||
}
|
||||
|
||||
return archives[0];
|
||||
}
|
||||
|
||||
async function downloadToFile(url: string, dest: string, maxRedirects = 5): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = request(url, (res) => {
|
||||
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) {
|
||||
const location = res.headers.location;
|
||||
if (!location || maxRedirects <= 0) {
|
||||
reject(new Error("Redirect loop or missing Location header"));
|
||||
return;
|
||||
}
|
||||
const redirectUrl = new URL(location, url).href;
|
||||
resolve(downloadToFile(redirectUrl, dest, maxRedirects - 1));
|
||||
return;
|
||||
}
|
||||
if (!res.statusCode || res.statusCode >= 400) {
|
||||
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading file`));
|
||||
return;
|
||||
}
|
||||
const out = createWriteStream(dest);
|
||||
pipeline(res, out).then(resolve).catch(reject);
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function findSignalCliBinary(root: string): Promise<string | null> {
|
||||
const candidates: string[] = [];
|
||||
const enqueue = async (dir: string, depth: number) => {
|
||||
if (depth > 3) {
|
||||
return;
|
||||
}
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await enqueue(full, depth + 1);
|
||||
} else if (entry.isFile() && entry.name === "signal-cli") {
|
||||
candidates.push(full);
|
||||
}
|
||||
}
|
||||
};
|
||||
await enqueue(root, 0);
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brew-based install (used on architectures without an official native build)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function resolveBrewSignalCliPath(brewExe: string): Promise<string | null> {
|
||||
try {
|
||||
const result = await runCommandWithTimeout([brewExe, "--prefix", "signal-cli"], {
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
if (result.code === 0 && result.stdout.trim()) {
|
||||
const prefix = result.stdout.trim();
|
||||
// Homebrew installs the wrapper script at <prefix>/bin/signal-cli
|
||||
const candidate = path.join(prefix, "bin", "signal-cli");
|
||||
try {
|
||||
await fs.access(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Fall back to searching the prefix
|
||||
return findSignalCliBinary(prefix);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function installSignalCliViaBrew(runtime: RuntimeEnv): Promise<SignalInstallResult> {
|
||||
const brewExe = resolveBrewExecutable();
|
||||
if (!brewExe) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
`No native signal-cli build is available for ${process.arch}. ` +
|
||||
"Install Homebrew (https://brew.sh) and try again, or install signal-cli manually.",
|
||||
};
|
||||
}
|
||||
|
||||
runtime.log(`Installing signal-cli via Homebrew (${brewExe})…`);
|
||||
const result = await runCommandWithTimeout([brewExe, "install", "signal-cli"], {
|
||||
timeoutMs: 15 * 60_000, // brew builds from source; can take a while
|
||||
});
|
||||
|
||||
if (result.code !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `brew install signal-cli failed (exit ${result.code}): ${result.stderr.trim().slice(0, 200)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const cliPath = await resolveBrewSignalCliPath(brewExe);
|
||||
if (!cliPath) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "brew install succeeded but signal-cli binary was not found.",
|
||||
};
|
||||
}
|
||||
|
||||
// Extract version from the installed binary.
|
||||
let version: string | undefined;
|
||||
try {
|
||||
const vResult = await runCommandWithTimeout([cliPath, "--version"], {
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
// Output is typically "signal-cli 0.13.24"
|
||||
version = vResult.stdout.trim().replace(/^signal-cli\s+/, "") || undefined;
|
||||
} catch {
|
||||
// non-critical; leave version undefined
|
||||
}
|
||||
|
||||
return { ok: true, cliPath, version };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Direct download install (used when an official native asset is available)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function installSignalCliFromRelease(runtime: RuntimeEnv): Promise<SignalInstallResult> {
|
||||
const apiUrl = "https://api.github.com/repos/AsamK/signal-cli/releases/latest";
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: {
|
||||
"User-Agent": "openclaw",
|
||||
Accept: "application/vnd.github+json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Failed to fetch release info (${response.status})`,
|
||||
};
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as ReleaseResponse;
|
||||
const version = payload.tag_name?.replace(/^v/, "") ?? "unknown";
|
||||
const assets = payload.assets ?? [];
|
||||
const asset = pickAsset(assets, process.platform, process.arch);
|
||||
|
||||
if (!asset) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "No compatible release asset found for this platform.",
|
||||
};
|
||||
}
|
||||
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-"));
|
||||
const archivePath = path.join(tmpDir, asset.name);
|
||||
|
||||
runtime.log(`Downloading signal-cli ${version} (${asset.name})…`);
|
||||
await downloadToFile(asset.browser_download_url, archivePath);
|
||||
|
||||
const installRoot = path.join(CONFIG_DIR, "tools", "signal-cli", version);
|
||||
await fs.mkdir(installRoot, { recursive: true });
|
||||
|
||||
if (!looksLikeArchive(asset.name.toLowerCase())) {
|
||||
return { ok: false, error: `Unsupported archive type: ${asset.name}` };
|
||||
}
|
||||
try {
|
||||
await extractSignalCliArchive(archivePath, installRoot, 60_000);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
ok: false,
|
||||
error: `Failed to extract ${asset.name}: ${message}`,
|
||||
};
|
||||
}
|
||||
|
||||
const cliPath = await findSignalCliBinary(installRoot);
|
||||
if (!cliPath) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `signal-cli binary not found after extracting ${asset.name}`,
|
||||
};
|
||||
}
|
||||
|
||||
await fs.chmod(cliPath, 0o755).catch(() => {});
|
||||
|
||||
return { ok: true, cliPath, version };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function installSignalCli(runtime: RuntimeEnv): Promise<SignalInstallResult> {
|
||||
if (process.platform === "win32") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Signal CLI auto-install is not supported on Windows yet.",
|
||||
};
|
||||
}
|
||||
|
||||
// The official signal-cli GitHub releases only ship a native binary for
|
||||
// x86-64 Linux. On other architectures (arm64, armv7, etc.) we delegate
|
||||
// to Homebrew which builds from source and bundles the JRE automatically.
|
||||
const hasNativeRelease = process.platform !== "linux" || process.arch === "x64";
|
||||
|
||||
if (hasNativeRelease) {
|
||||
return installSignalCliFromRelease(runtime);
|
||||
}
|
||||
|
||||
return installSignalCliViaBrew(runtime);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { execFile as execFileType } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -16,7 +17,7 @@ vi.mock("node:child_process", async () => {
|
||||
() => vi.importActual<typeof import("node:child_process")>("node:child_process"),
|
||||
{
|
||||
spawn: spawnMock,
|
||||
execFile: execFileMock as unknown as typeof import("node:child_process").execFile,
|
||||
execFile: execFileMock as unknown as typeof execFileType,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -78,6 +78,7 @@ describe("deriveSessionChatType", () => {
|
||||
{ key: "agent:main:telegram:dm:123456", expected: "direct" },
|
||||
{ key: "telegram:dm:123456", expected: "direct" },
|
||||
{ key: "discord:acc-1:guild-123:channel-456", expected: "channel" },
|
||||
{ key: "12345-678@g.us", expected: "group" },
|
||||
{ key: "agent:main:main", expected: "unknown" },
|
||||
{ key: "agent:main", expected: "unknown" },
|
||||
{ key: "", expected: "unknown" },
|
||||
|
||||
@@ -2,11 +2,13 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { collectDiscordSecurityAuditFindings } from "../../extensions/discord/api.js";
|
||||
import { collectSlackSecurityAuditFindings } from "../../extensions/slack/api.js";
|
||||
import { collectSynologyChatSecurityAuditFindings } from "../../extensions/synology-chat/api.js";
|
||||
import { collectTelegramSecurityAuditFindings } from "../../extensions/telegram/api.js";
|
||||
import { collectZalouserSecurityAuditFindings } from "../../extensions/zalouser/api.js";
|
||||
import {
|
||||
collectDiscordSecurityAuditFindings,
|
||||
collectSlackSecurityAuditFindings,
|
||||
collectSynologyChatSecurityAuditFindings,
|
||||
collectTelegramSecurityAuditFindings,
|
||||
collectZalouserSecurityAuditFindings,
|
||||
} from "../../test/helpers/channels/security-audit-contract.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { saveExecApprovals } from "../infra/exec-approvals.js";
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { expect, type MockInstance } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
|
||||
export function createWhatsAppPollFixture() {
|
||||
const cfg = { marker: "resolved-cfg" } as OpenClawConfig;
|
||||
const poll = {
|
||||
question: "Lunch?",
|
||||
options: ["Pizza", "Sushi"],
|
||||
maxSelections: 1,
|
||||
};
|
||||
return {
|
||||
cfg,
|
||||
poll,
|
||||
to: "+1555",
|
||||
accountId: "work",
|
||||
};
|
||||
}
|
||||
|
||||
export function expectWhatsAppPollSent(
|
||||
sendPollWhatsApp: MockInstance,
|
||||
params: {
|
||||
cfg: OpenClawConfig;
|
||||
poll: { question: string; options: string[]; maxSelections: number };
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
},
|
||||
) {
|
||||
expect(sendPollWhatsApp).toHaveBeenCalledWith(params.to ?? "+1555", params.poll, {
|
||||
verbose: false,
|
||||
accountId: params.accountId ?? "work",
|
||||
cfg: params.cfg,
|
||||
});
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { normalizeIMessageHandle } from "../channels/plugins/normalize/imessage.js";
|
||||
import type { ChannelOutboundAdapter, ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import { resolveOutboundSendDep } from "../infra/outbound/send-deps.js";
|
||||
import { collectStatusIssuesFromLastError } from "../plugin-sdk/status-helpers.js";
|
||||
|
||||
const defaultIMessageOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
sendText: async ({ to, text, accountId, replyToId, deps, cfg }) => {
|
||||
const sendIMessage = resolveOutboundSendDep<
|
||||
(
|
||||
target: string,
|
||||
content: string,
|
||||
opts?: Record<string, unknown>,
|
||||
) => Promise<{ messageId: string }>
|
||||
>(deps, "imessage");
|
||||
const result = await sendIMessage?.(to, text, {
|
||||
config: cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToId: replyToId ?? undefined,
|
||||
});
|
||||
return { channel: "imessage", messageId: result?.messageId ?? "imessage-test-stub" };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, deps, cfg, mediaLocalRoots }) => {
|
||||
const sendIMessage = resolveOutboundSendDep<
|
||||
(
|
||||
target: string,
|
||||
content: string,
|
||||
opts?: Record<string, unknown>,
|
||||
) => Promise<{ messageId: string }>
|
||||
>(deps, "imessage");
|
||||
const result = await sendIMessage?.(to, text, {
|
||||
config: cfg,
|
||||
mediaUrl,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToId: replyToId ?? undefined,
|
||||
mediaLocalRoots,
|
||||
});
|
||||
return { channel: "imessage", messageId: result?.messageId ?? "imessage-test-stub" };
|
||||
},
|
||||
};
|
||||
|
||||
export const createIMessageTestPlugin = (params?: {
|
||||
outbound?: ChannelOutboundAdapter;
|
||||
}): ChannelPlugin => ({
|
||||
id: "imessage",
|
||||
meta: {
|
||||
id: "imessage",
|
||||
label: "iMessage",
|
||||
selectionLabel: "iMessage (imsg)",
|
||||
docsPath: "/channels/imessage",
|
||||
blurb: "iMessage test stub.",
|
||||
aliases: ["imsg"],
|
||||
},
|
||||
capabilities: { chatTypes: ["direct", "group"], media: true },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
status: {
|
||||
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("imessage", accounts),
|
||||
},
|
||||
outbound: params?.outbound ?? defaultIMessageOutbound,
|
||||
messaging: {
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (/^(imessage:|sms:|auto:|chat_id:|chat_guid:|chat_identifier:)/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
if (trimmed.includes("@")) {
|
||||
return true;
|
||||
}
|
||||
return /^\+?\d{3,}$/.test(trimmed);
|
||||
},
|
||||
hint: "<handle|chat_id:ID>",
|
||||
},
|
||||
normalizeTarget: (raw) => normalizeIMessageHandle(raw),
|
||||
},
|
||||
});
|
||||
@@ -3,19 +3,13 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
assertWebChannel,
|
||||
CONFIG_DIR,
|
||||
ensureDir,
|
||||
jidToE164,
|
||||
normalizeE164,
|
||||
resolveConfigDir,
|
||||
resolveHomeDir,
|
||||
resolveJidToE164,
|
||||
resolveUserPath,
|
||||
shortenHomeInString,
|
||||
shortenHomePath,
|
||||
sleep,
|
||||
toWhatsappJid,
|
||||
} from "./utils.js";
|
||||
|
||||
async function withTempDir<T>(
|
||||
@@ -50,74 +44,6 @@ describe("sleep", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertWebChannel", () => {
|
||||
it("accepts valid channel", () => {
|
||||
expect(() => assertWebChannel("web")).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws for invalid channel", () => {
|
||||
expect(() => assertWebChannel("bad" as string)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeE164 & toWhatsappJid", () => {
|
||||
it("strips formatting and prefixes", () => {
|
||||
expect(normalizeE164("whatsapp:(555) 123-4567")).toBe("+5551234567");
|
||||
expect(toWhatsappJid("whatsapp:+555 123 4567")).toBe("5551234567@s.whatsapp.net");
|
||||
});
|
||||
|
||||
it("preserves existing JIDs", () => {
|
||||
expect(toWhatsappJid("123456789-987654321@g.us")).toBe("123456789-987654321@g.us");
|
||||
expect(toWhatsappJid("whatsapp:123456789-987654321@g.us")).toBe("123456789-987654321@g.us");
|
||||
expect(toWhatsappJid("1555123@s.whatsapp.net")).toBe("1555123@s.whatsapp.net");
|
||||
});
|
||||
});
|
||||
|
||||
describe("jidToE164", () => {
|
||||
it("maps @lid using reverse mapping file", () => {
|
||||
const mappingPath = path.join(CONFIG_DIR, "credentials", "lid-mapping-123_reverse.json");
|
||||
const original = fs.readFileSync;
|
||||
const spy = vi.spyOn(fs, "readFileSync").mockImplementation((...args) => {
|
||||
if (args[0] === mappingPath) {
|
||||
return `"5551234"`;
|
||||
}
|
||||
return original(...args);
|
||||
});
|
||||
expect(jidToE164("123@lid")).toBe("+5551234");
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("maps @lid from authDir mapping files", async () => {
|
||||
await withTempDir("openclaw-auth-", (authDir) => {
|
||||
const mappingPath = path.join(authDir, "lid-mapping-456_reverse.json");
|
||||
fs.writeFileSync(mappingPath, JSON.stringify("5559876"));
|
||||
expect(jidToE164("456@lid", { authDir })).toBe("+5559876");
|
||||
});
|
||||
});
|
||||
|
||||
it("maps @hosted.lid from authDir mapping files", async () => {
|
||||
await withTempDir("openclaw-auth-", (authDir) => {
|
||||
const mappingPath = path.join(authDir, "lid-mapping-789_reverse.json");
|
||||
fs.writeFileSync(mappingPath, JSON.stringify(4440001));
|
||||
expect(jidToE164("789@hosted.lid", { authDir })).toBe("+4440001");
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts hosted PN JIDs", () => {
|
||||
expect(jidToE164("1555000:2@hosted")).toBe("+1555000");
|
||||
});
|
||||
|
||||
it("falls back through lidMappingDirs in order", async () => {
|
||||
await withTempDir("openclaw-lid-a-", async (first) => {
|
||||
await withTempDir("openclaw-lid-b-", (second) => {
|
||||
const mappingPath = path.join(second, "lid-mapping-321_reverse.json");
|
||||
fs.writeFileSync(mappingPath, JSON.stringify("123321"));
|
||||
expect(jidToE164("321@lid", { lidMappingDirs: [first, second] })).toBe("+123321");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveConfigDir", () => {
|
||||
it("prefers ~/.openclaw when legacy dir is missing", async () => {
|
||||
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-config-dir-"));
|
||||
@@ -178,32 +104,6 @@ describe("shortenHomeInString", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveJidToE164", () => {
|
||||
it("resolves @lid via lidLookup when mapping file is missing", async () => {
|
||||
const lidLookup = {
|
||||
getPNForLID: vi.fn().mockResolvedValue("777:0@s.whatsapp.net"),
|
||||
};
|
||||
await expect(resolveJidToE164("777@lid", { lidLookup })).resolves.toBe("+777");
|
||||
expect(lidLookup.getPNForLID).toHaveBeenCalledWith("777@lid");
|
||||
});
|
||||
|
||||
it("skips lidLookup for non-lid JIDs", async () => {
|
||||
const lidLookup = {
|
||||
getPNForLID: vi.fn().mockResolvedValue("888:0@s.whatsapp.net"),
|
||||
};
|
||||
await expect(resolveJidToE164("888@s.whatsapp.net", { lidLookup })).resolves.toBe("+888");
|
||||
expect(lidLookup.getPNForLID).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when lidLookup throws", async () => {
|
||||
const lidLookup = {
|
||||
getPNForLID: vi.fn().mockRejectedValue(new Error("lookup failed")),
|
||||
};
|
||||
await expect(resolveJidToE164("777@lid", { lidLookup })).resolves.toBeNull();
|
||||
expect(lidLookup.getPNForLID).toHaveBeenCalledWith("777@lid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveUserPath", () => {
|
||||
it("expands ~ to home dir", () => {
|
||||
expect(resolveUserPath("~", {}, () => "/Users/thoffman")).toBe(path.resolve("/Users/thoffman"));
|
||||
|
||||
@@ -13,6 +13,10 @@ const allowedNonExtensionTests = new Set<string>([
|
||||
"src/agents/pi-embedded-runner-extraparams.test.ts",
|
||||
"src/channels/plugins/contracts/dm-policy.contract.test.ts",
|
||||
"src/channels/plugins/contracts/group-policy.contract.test.ts",
|
||||
"src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts",
|
||||
"src/commands/onboard-channels.e2e.test.ts",
|
||||
"src/gateway/hooks.test.ts",
|
||||
"src/infra/outbound/deliver.test.ts",
|
||||
"src/plugins/interactive.test.ts",
|
||||
"src/plugins/contracts/discovery.contract.test.ts",
|
||||
]);
|
||||
@@ -156,7 +160,6 @@ describe("non-extension test boundaries", () => {
|
||||
it("keeps bundled plugin public-surface imports on an explicit core allowlist", () => {
|
||||
const allowed = new Set([
|
||||
"src/auto-reply/reply.triggers.trigger-handling.test-harness.ts",
|
||||
"src/channels/plugins/contracts/slack-outbound-harness.ts",
|
||||
"src/commands/channel-test-registry.ts",
|
||||
"src/plugin-sdk/testing.ts",
|
||||
]);
|
||||
|
||||
5
test/helpers/bundled-runtime-sidecars.ts
Normal file
5
test/helpers/bundled-runtime-sidecars.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const TEST_BUNDLED_RUNTIME_SIDECAR_PATHS = [
|
||||
"dist/extensions/discord/runtime-api.js",
|
||||
"dist/extensions/slack/helper-api.js",
|
||||
"dist/extensions/telegram/thread-bindings-runtime.js",
|
||||
] as const;
|
||||
5
test/helpers/channels/channel-media-roots-contract.ts
Normal file
5
test/helpers/channels/channel-media-roots-contract.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
|
||||
resolveIMessageAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots,
|
||||
} from "../../../extensions/imessage/contract-api.js";
|
||||
6
test/helpers/channels/command-contract.ts
Normal file
6
test/helpers/channels/command-contract.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { buildTelegramModelsProviderChannelData } from "../../../extensions/telegram/contract-api.js";
|
||||
export { whatsappCommandPolicy } from "../../../extensions/whatsapp/contract-api.js";
|
||||
export {
|
||||
isWhatsAppGroupJid,
|
||||
normalizeWhatsAppTarget,
|
||||
} from "../../../extensions/whatsapp/contract-api.js";
|
||||
1
test/helpers/channels/imessage-test-plugin.ts
Normal file
1
test/helpers/channels/imessage-test-plugin.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createIMessageTestPlugin } from "../../../extensions/imessage/contract-api.js";
|
||||
12
test/helpers/channels/interactive-contract.ts
Normal file
12
test/helpers/channels/interactive-contract.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
DiscordInteractiveHandlerContext,
|
||||
DiscordInteractiveHandlerRegistration,
|
||||
} from "../../../extensions/discord/contract-api.js";
|
||||
export type {
|
||||
SlackInteractiveHandlerContext,
|
||||
SlackInteractiveHandlerRegistration,
|
||||
} from "../../../extensions/slack/contract-api.js";
|
||||
export type {
|
||||
TelegramInteractiveHandlerContext,
|
||||
TelegramInteractiveHandlerRegistration,
|
||||
} from "../../../extensions/telegram/contract-api.js";
|
||||
@@ -1 +1 @@
|
||||
export { matrixSetupAdapter, matrixSetupWizard } from "../../../extensions/matrix/api.js";
|
||||
export { matrixSetupAdapter, matrixSetupWizard } from "../../../extensions/matrix/contract-api.js";
|
||||
@@ -1,11 +1,11 @@
|
||||
import { expect, it, type Mock, vi } from "vitest";
|
||||
import { createSlackOutboundPayloadHarness } from "../../../extensions/slack/contract-api.js";
|
||||
import {
|
||||
chunkTextForOutbound as chunkZaloTextForOutbound,
|
||||
sendPayloadWithChunkedTextAndMedia as sendZaloPayloadWithChunkedTextAndMedia,
|
||||
} from "../../../extensions/zalo/runtime-api.js";
|
||||
import { sendPayloadWithChunkedTextAndMedia as sendZalouserPayloadWithChunkedTextAndMedia } from "../../../extensions/zalouser/runtime-api.js";
|
||||
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
|
||||
import { createSlackOutboundPayloadHarness } from "../../../src/channels/plugins/contracts/slack-outbound-harness.js";
|
||||
import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/test-helpers.js";
|
||||
import { createDirectTextMediaOutbound } from "../../../src/channels/plugins/outbound/direct-text-media.js";
|
||||
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
|
||||
|
||||
9
test/helpers/channels/policy-contract.ts
Normal file
9
test/helpers/channels/policy-contract.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { whatsappAccessControlTesting } from "../../../extensions/whatsapp/contract-api.js";
|
||||
export {
|
||||
evaluateZaloGroupAccess,
|
||||
resolveZaloRuntimeGroupPolicy,
|
||||
} from "../../../extensions/zalo/contract-api.js";
|
||||
export {
|
||||
isSignalSenderAllowed,
|
||||
type SignalSender,
|
||||
} from "../../../extensions/signal/contract-api.js";
|
||||
5
test/helpers/channels/security-audit-contract.ts
Normal file
5
test/helpers/channels/security-audit-contract.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { collectDiscordSecurityAuditFindings } from "../../../extensions/discord/contract-api.js";
|
||||
export { collectSlackSecurityAuditFindings } from "../../../extensions/slack/contract-api.js";
|
||||
export { collectSynologyChatSecurityAuditFindings } from "../../../extensions/synology-chat/contract-api.js";
|
||||
export { collectTelegramSecurityAuditFindings } from "../../../extensions/telegram/contract-api.js";
|
||||
export { collectZalouserSecurityAuditFindings } from "../../../extensions/zalouser/contract-api.js";
|
||||
@@ -1,8 +0,0 @@
|
||||
export type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
export {
|
||||
__testing,
|
||||
registerSessionBindingAdapter,
|
||||
} from "../../../src/infra/outbound/session-binding-service.js";
|
||||
export { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
|
||||
export { resolveAgentRoute } from "../../../src/routing/resolve-route.js";
|
||||
export { createTestRegistry } from "../../../src/test-utils/channel-plugins.js";
|
||||
8
test/helpers/providers/anthropic-contract.ts
Normal file
8
test/helpers/providers/anthropic-contract.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
createAnthropicBetaHeadersWrapper,
|
||||
createAnthropicFastModeWrapper,
|
||||
createAnthropicServiceTierWrapper,
|
||||
resolveAnthropicBetas,
|
||||
resolveAnthropicFastMode,
|
||||
resolveAnthropicServiceTier,
|
||||
} from "../../../extensions/anthropic/contract-api.js";
|
||||
@@ -21,6 +21,9 @@ describe("isUnitConfigTestFile", () => {
|
||||
expect(isUnitConfigTestFile("src/infra/git-root.test.ts")).toBe(false);
|
||||
expect(isUnitConfigTestFile("src/infra/home-dir.test.ts")).toBe(false);
|
||||
expect(isUnitConfigTestFile("src/infra/openclaw-exec-env.test.ts")).toBe(false);
|
||||
expect(
|
||||
isUnitConfigTestFile(bundledPluginFile("matrix", "src/migration-snapshot.test.ts")),
|
||||
).toBe(false);
|
||||
expect(isUnitConfigTestFile("src/infra/openclaw-root.test.ts")).toBe(false);
|
||||
expect(isUnitConfigTestFile("src/infra/package-json.test.ts")).toBe(false);
|
||||
expect(isUnitConfigTestFile("src/infra/path-env.test.ts")).toBe(false);
|
||||
|
||||
Reference in New Issue
Block a user