mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-27 06:50:40 +00:00
Merge remote-tracking branch 'origin/main' into codex/pr-12077-matrix-plugin
# Conflicts: # extensions/matrix/package.json # extensions/matrix/src/matrix/monitor/events.ts # extensions/matrix/src/matrix/send.ts # pnpm-lock.yaml
This commit is contained in:
@@ -1,11 +1,8 @@
|
||||
{
|
||||
"name": "@openclaw/bluebubbles",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.25",
|
||||
"description": "OpenClaw BlueBubbles channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
@@ -12,6 +12,7 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
|
||||
baseUrl: string;
|
||||
password: string;
|
||||
accountId: string;
|
||||
allowPrivateNetwork: boolean;
|
||||
} {
|
||||
const account = resolveBlueBubblesAccount({
|
||||
cfg: params.cfg ?? {},
|
||||
@@ -25,5 +26,10 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
|
||||
if (!password) {
|
||||
throw new Error("BlueBubbles password is required");
|
||||
}
|
||||
return { baseUrl, password, accountId: account.accountId };
|
||||
return {
|
||||
baseUrl,
|
||||
password,
|
||||
accountId: account.accountId,
|
||||
allowPrivateNetwork: account.config.allowPrivateNetwork === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,6 +47,22 @@ describe("bluebubblesMessageActions", () => {
|
||||
const handleAction = bluebubblesMessageActions.handleAction!;
|
||||
const callHandleAction = (ctx: Omit<Parameters<typeof handleAction>[0], "channel">) =>
|
||||
handleAction({ channel: "bluebubbles", ...ctx });
|
||||
const blueBubblesConfig = (): OpenClawConfig => ({
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
});
|
||||
const runReactAction = async (params: Record<string, unknown>) => {
|
||||
return await callHandleAction({
|
||||
action: "react",
|
||||
params,
|
||||
cfg: blueBubblesConfig(),
|
||||
accountId: null,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -285,23 +301,10 @@ describe("bluebubblesMessageActions", () => {
|
||||
it("sends reaction successfully with chatGuid", async () => {
|
||||
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await callHandleAction({
|
||||
action: "react",
|
||||
params: {
|
||||
emoji: "❤️",
|
||||
messageId: "msg-123",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
},
|
||||
cfg,
|
||||
accountId: null,
|
||||
const result = await runReactAction({
|
||||
emoji: "❤️",
|
||||
messageId: "msg-123",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
});
|
||||
|
||||
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
||||
@@ -320,24 +323,11 @@ describe("bluebubblesMessageActions", () => {
|
||||
it("sends reaction removal successfully", async () => {
|
||||
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await callHandleAction({
|
||||
action: "react",
|
||||
params: {
|
||||
emoji: "❤️",
|
||||
messageId: "msg-123",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
remove: true,
|
||||
},
|
||||
cfg,
|
||||
accountId: null,
|
||||
const result = await runReactAction({
|
||||
emoji: "❤️",
|
||||
messageId: "msg-123",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
remove: true,
|
||||
});
|
||||
|
||||
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
||||
|
||||
@@ -2,13 +2,13 @@ import {
|
||||
BLUEBUBBLES_ACTION_NAMES,
|
||||
BLUEBUBBLES_ACTIONS,
|
||||
createActionGate,
|
||||
extractToolSend,
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringParam,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelMessageActionName,
|
||||
type ChannelToolSend,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { sendBlueBubblesAttachment } from "./attachments.js";
|
||||
@@ -112,18 +112,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
return Array.from(actions);
|
||||
},
|
||||
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
|
||||
extractToolSend: ({ args }): ChannelToolSend | null => {
|
||||
const action = typeof args.action === "string" ? args.action.trim() : "";
|
||||
if (action !== "sendMessage") {
|
||||
return null;
|
||||
}
|
||||
const to = typeof args.to === "string" ? args.to : undefined;
|
||||
if (!to) {
|
||||
return null;
|
||||
}
|
||||
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
||||
return { to, accountId };
|
||||
},
|
||||
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
|
||||
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
||||
const account = resolveBlueBubblesAccount({
|
||||
cfg: cfg,
|
||||
|
||||
@@ -64,6 +64,24 @@ describe("downloadBlueBubblesAttachment", () => {
|
||||
setBlueBubblesRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
async function expectAttachmentTooLarge(params: { bufferBytes: number; maxBytes?: number }) {
|
||||
const largeBuffer = new Uint8Array(params.bufferBytes);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
|
||||
});
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-large" };
|
||||
await expect(
|
||||
downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
...(params.maxBytes === undefined ? {} : { maxBytes: params.maxBytes }),
|
||||
}),
|
||||
).rejects.toThrow("too large");
|
||||
}
|
||||
|
||||
it("throws when guid is missing", async () => {
|
||||
const attachment: BlueBubblesAttachment = {};
|
||||
await expect(
|
||||
@@ -175,38 +193,14 @@ describe("downloadBlueBubblesAttachment", () => {
|
||||
});
|
||||
|
||||
it("throws when attachment exceeds max bytes", async () => {
|
||||
const largeBuffer = new Uint8Array(10 * 1024 * 1024);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
|
||||
await expectAttachmentTooLarge({
|
||||
bufferBytes: 10 * 1024 * 1024,
|
||||
maxBytes: 5 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-large" };
|
||||
await expect(
|
||||
downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
maxBytes: 5 * 1024 * 1024,
|
||||
}),
|
||||
).rejects.toThrow("too large");
|
||||
});
|
||||
|
||||
it("uses default max bytes when not specified", async () => {
|
||||
const largeBuffer = new Uint8Array(9 * 1024 * 1024);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
|
||||
});
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-large" };
|
||||
await expect(
|
||||
downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
}),
|
||||
).rejects.toThrow("too large");
|
||||
await expectAttachmentTooLarge({ bufferBytes: 9 * 1024 * 1024 });
|
||||
});
|
||||
|
||||
it("uses attachment mimeType as fallback when response has no content-type", async () => {
|
||||
@@ -274,6 +268,49 @@ describe("downloadBlueBubblesAttachment", () => {
|
||||
expect(calledUrl).toContain("password=config-password");
|
||||
expect(result.buffer).toEqual(new Uint8Array([1]));
|
||||
});
|
||||
|
||||
it("passes ssrfPolicy with allowPrivateNetwork when config enables it", async () => {
|
||||
const mockBuffer = new Uint8Array([1]);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
||||
});
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-ssrf" };
|
||||
await downloadBlueBubblesAttachment(attachment, {
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
allowPrivateNetwork: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true });
|
||||
});
|
||||
|
||||
it("does not pass ssrfPolicy when allowPrivateNetwork is not set", async () => {
|
||||
const mockBuffer = new Uint8Array([1]);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
||||
});
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-no-ssrf" };
|
||||
await downloadBlueBubblesAttachment(attachment, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(fetchMediaArgs.ssrfPolicy).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendBlueBubblesAttachment", () => {
|
||||
|
||||
@@ -82,7 +82,7 @@ export async function downloadBlueBubblesAttachment(
|
||||
if (!guid) {
|
||||
throw new Error("BlueBubbles attachment guid is required");
|
||||
}
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
const { baseUrl, password, allowPrivateNetwork } = resolveAccount(opts);
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
|
||||
@@ -94,6 +94,7 @@ export async function downloadBlueBubblesAttachment(
|
||||
url,
|
||||
filePathHint: attachment.transferName ?? attachment.guid ?? "attachment",
|
||||
maxBytes,
|
||||
ssrfPolicy: allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined,
|
||||
fetchImpl: async (input, init) =>
|
||||
await blueBubblesFetchWithTimeout(
|
||||
resolveRequestUrl(input),
|
||||
|
||||
@@ -22,6 +22,44 @@ installBlueBubblesFetchTestHooks({
|
||||
});
|
||||
|
||||
describe("chat", () => {
|
||||
function mockOkTextResponse() {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
}
|
||||
|
||||
async function expectCalledUrlIncludesPassword(params: {
|
||||
password: string;
|
||||
invoke: () => Promise<void>;
|
||||
}) {
|
||||
mockOkTextResponse();
|
||||
await params.invoke();
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain(`password=${params.password}`);
|
||||
}
|
||||
|
||||
async function expectCalledUrlUsesConfigCredentials(params: {
|
||||
serverHost: string;
|
||||
password: string;
|
||||
invoke: (cfg: {
|
||||
channels: { bluebubbles: { serverUrl: string; password: string } };
|
||||
}) => Promise<void>;
|
||||
}) {
|
||||
mockOkTextResponse();
|
||||
await params.invoke({
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: `http://${params.serverHost}`,
|
||||
password: params.password,
|
||||
},
|
||||
},
|
||||
});
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain(params.serverHost);
|
||||
expect(calledUrl).toContain(`password=${params.password}`);
|
||||
}
|
||||
|
||||
describe("markBlueBubblesChatRead", () => {
|
||||
it("does nothing when chatGuid is empty or whitespace", async () => {
|
||||
for (const chatGuid of ["", " "]) {
|
||||
@@ -73,18 +111,14 @@ describe("chat", () => {
|
||||
});
|
||||
|
||||
it("includes password in URL query", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await markBlueBubblesChatRead("chat-123", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
await expectCalledUrlIncludesPassword({
|
||||
password: "my-secret",
|
||||
invoke: () =>
|
||||
markBlueBubblesChatRead("chat-123", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "my-secret",
|
||||
}),
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("password=my-secret");
|
||||
});
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
@@ -119,25 +153,14 @@ describe("chat", () => {
|
||||
});
|
||||
|
||||
it("resolves credentials from config", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
await expectCalledUrlUsesConfigCredentials({
|
||||
serverHost: "config-server:9999",
|
||||
password: "config-pass",
|
||||
invoke: (cfg) =>
|
||||
markBlueBubblesChatRead("chat-123", {
|
||||
cfg,
|
||||
}),
|
||||
});
|
||||
|
||||
await markBlueBubblesChatRead("chat-123", {
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://config-server:9999",
|
||||
password: "config-pass",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("config-server:9999");
|
||||
expect(calledUrl).toContain("password=config-pass");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -536,18 +559,14 @@ describe("chat", () => {
|
||||
});
|
||||
|
||||
it("includes password in URL query", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
await expectCalledUrlIncludesPassword({
|
||||
password: "my-secret",
|
||||
invoke: () =>
|
||||
setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "my-secret",
|
||||
}),
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("password=my-secret");
|
||||
});
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
@@ -582,25 +601,14 @@ describe("chat", () => {
|
||||
});
|
||||
|
||||
it("resolves credentials from config", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
await expectCalledUrlUsesConfigCredentials({
|
||||
serverHost: "config-server:9999",
|
||||
password: "config-pass",
|
||||
invoke: (cfg) =>
|
||||
setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", {
|
||||
cfg,
|
||||
}),
|
||||
});
|
||||
|
||||
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", {
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://config-server:9999",
|
||||
password: "config-pass",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain("config-server:9999");
|
||||
expect(calledUrl).toContain("password=config-pass");
|
||||
});
|
||||
|
||||
it("includes filename in multipart body", async () => {
|
||||
|
||||
@@ -43,6 +43,7 @@ const bluebubblesAccountSchema = z
|
||||
mediaMaxMb: z.number().int().positive().optional(),
|
||||
mediaLocalRoots: z.array(z.string()).optional(),
|
||||
sendReadReceipts: z.boolean().optional(),
|
||||
allowPrivateNetwork: z.boolean().optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { normalizeWebhookPath, type OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
import { getBlueBubblesRuntime } from "./runtime.js";
|
||||
import type { BlueBubblesAccountConfig } from "./types.js";
|
||||
|
||||
export { normalizeWebhookPath };
|
||||
|
||||
export type BlueBubblesRuntimeEnv = {
|
||||
log?: (message: string) => void;
|
||||
error?: (message: string) => void;
|
||||
@@ -30,18 +32,6 @@ export type WebhookTarget = {
|
||||
|
||||
export const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
|
||||
|
||||
export function normalizeWebhookPath(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return "/";
|
||||
}
|
||||
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
if (withSlash.length > 1 && withSlash.endsWith("/")) {
|
||||
return withSlash.slice(0, -1);
|
||||
}
|
||||
return withSlash;
|
||||
}
|
||||
|
||||
export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
|
||||
const raw = config?.webhookPath?.trim();
|
||||
if (raw) {
|
||||
|
||||
@@ -176,6 +176,28 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
|
||||
let next = cfg;
|
||||
const resolvedAccount = resolveBlueBubblesAccount({ cfg: next, accountId });
|
||||
const validateServerUrlInput = (value: unknown): string | undefined => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return "Required";
|
||||
}
|
||||
try {
|
||||
const normalized = normalizeBlueBubblesServerUrl(trimmed);
|
||||
new URL(normalized);
|
||||
return undefined;
|
||||
} catch {
|
||||
return "Invalid URL format";
|
||||
}
|
||||
};
|
||||
const promptServerUrl = async (initialValue?: string): Promise<string> => {
|
||||
const entered = await prompter.text({
|
||||
message: "BlueBubbles server URL",
|
||||
placeholder: "http://192.168.1.100:1234",
|
||||
initialValue,
|
||||
validate: validateServerUrlInput,
|
||||
});
|
||||
return String(entered).trim();
|
||||
};
|
||||
|
||||
// Prompt for server URL
|
||||
let serverUrl = resolvedAccount.config.serverUrl?.trim();
|
||||
@@ -188,49 +210,14 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
].join("\n"),
|
||||
"BlueBubbles server URL",
|
||||
);
|
||||
const entered = await prompter.text({
|
||||
message: "BlueBubbles server URL",
|
||||
placeholder: "http://192.168.1.100:1234",
|
||||
validate: (value) => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return "Required";
|
||||
}
|
||||
try {
|
||||
const normalized = normalizeBlueBubblesServerUrl(trimmed);
|
||||
new URL(normalized);
|
||||
return undefined;
|
||||
} catch {
|
||||
return "Invalid URL format";
|
||||
}
|
||||
},
|
||||
});
|
||||
serverUrl = String(entered).trim();
|
||||
serverUrl = await promptServerUrl();
|
||||
} else {
|
||||
const keepUrl = await prompter.confirm({
|
||||
message: `BlueBubbles server URL already set (${serverUrl}). Keep it?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keepUrl) {
|
||||
const entered = await prompter.text({
|
||||
message: "BlueBubbles server URL",
|
||||
placeholder: "http://192.168.1.100:1234",
|
||||
initialValue: serverUrl,
|
||||
validate: (value) => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return "Required";
|
||||
}
|
||||
try {
|
||||
const normalized = normalizeBlueBubblesServerUrl(trimmed);
|
||||
new URL(normalized);
|
||||
return undefined;
|
||||
} catch {
|
||||
return "Invalid URL format";
|
||||
}
|
||||
},
|
||||
});
|
||||
serverUrl = String(entered).trim();
|
||||
serverUrl = await promptServerUrl(serverUrl);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,27 @@ describe("reactions", () => {
|
||||
});
|
||||
|
||||
describe("sendBlueBubblesReaction", () => {
|
||||
async function expectRemovedReaction(emoji: string) {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "msg-123",
|
||||
emoji,
|
||||
remove: true,
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.reaction).toBe("-love");
|
||||
}
|
||||
|
||||
it("throws when chatGuid is empty", async () => {
|
||||
await expect(
|
||||
sendBlueBubblesReaction({
|
||||
@@ -208,45 +229,11 @@ describe("reactions", () => {
|
||||
});
|
||||
|
||||
it("sends reaction removal with dash prefix", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "msg-123",
|
||||
emoji: "love",
|
||||
remove: true,
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.reaction).toBe("-love");
|
||||
await expectRemovedReaction("love");
|
||||
});
|
||||
|
||||
it("strips leading dash from emoji when remove flag is set", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "msg-123",
|
||||
emoji: "-love",
|
||||
remove: true,
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.reaction).toBe("-love");
|
||||
await expectRemovedReaction("-love");
|
||||
});
|
||||
|
||||
it("uses custom partIndex when provided", async () => {
|
||||
|
||||
@@ -44,6 +44,23 @@ function mockSendResponse(body: unknown) {
|
||||
});
|
||||
}
|
||||
|
||||
function mockNewChatSendResponse(guid: string) {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { guid },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
describe("send", () => {
|
||||
describe("resolveChatGuidForTarget", () => {
|
||||
const resolveHandleTargetGuid = async (data: Array<Record<string, unknown>>) => {
|
||||
@@ -453,20 +470,7 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
it("strips markdown when creating a new chat", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { guid: "new-msg-stripped" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
mockNewChatSendResponse("new-msg-stripped");
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15550009999", "**Welcome** to the _chat_!", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
@@ -483,20 +487,7 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
it("creates a new chat when handle target is missing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { guid: "new-msg-guid" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
mockNewChatSendResponse("new-msg-guid");
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
|
||||
@@ -53,6 +53,8 @@ export type BlueBubblesAccountConfig = {
|
||||
mediaLocalRoots?: string[];
|
||||
/** Send read receipts for incoming messages (default: true). */
|
||||
sendReadReceipts?: boolean;
|
||||
/** Allow fetching from private/internal IP addresses (e.g. localhost). Required for same-host BlueBubbles setups. */
|
||||
allowPrivateNetwork?: boolean;
|
||||
/** Per-group configuration keyed by chat GUID or identifier. */
|
||||
groups?: Record<string, BlueBubblesGroupConfig>;
|
||||
};
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
{
|
||||
"name": "@openclaw/copilot-proxy",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.25",
|
||||
"private": true,
|
||||
"description": "OpenClaw Copilot Proxy provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { approveDevicePairing, listDevicePairing } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
approveDevicePairing,
|
||||
listDevicePairing,
|
||||
resolveGatewayBindUrl,
|
||||
runPluginCommandWithTimeout,
|
||||
resolveTailnetHostWithRunner,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import qrcode from "qrcode-terminal";
|
||||
|
||||
function renderQrAscii(data: string): Promise<string> {
|
||||
@@ -37,77 +42,6 @@ type ResolveAuthResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type CommandResult = {
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
async function runFixedCommandWithTimeout(
|
||||
argv: string[],
|
||||
timeoutMs: number,
|
||||
): Promise<CommandResult> {
|
||||
return await new Promise((resolve) => {
|
||||
const [command, ...args] = argv;
|
||||
if (!command) {
|
||||
resolve({ code: 1, stdout: "", stderr: "command is required" });
|
||||
return;
|
||||
}
|
||||
const proc = spawn(command, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
const finalize = (result: CommandResult) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
proc.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
proc.stderr?.on("data", (chunk: Buffer | string) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
timer = setTimeout(() => {
|
||||
proc.kill("SIGKILL");
|
||||
finalize({
|
||||
code: 124,
|
||||
stdout,
|
||||
stderr: stderr || `command timed out after ${timeoutMs}ms`,
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
proc.on("error", (err) => {
|
||||
finalize({
|
||||
code: 1,
|
||||
stdout,
|
||||
stderr: err.message,
|
||||
});
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
finalize({
|
||||
code: code ?? 1,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null {
|
||||
const candidate = raw.trim();
|
||||
if (!candidate) {
|
||||
@@ -239,48 +173,12 @@ function pickTailnetIPv4(): string | null {
|
||||
}
|
||||
|
||||
async function resolveTailnetHost(): Promise<string | null> {
|
||||
const candidates = ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const result = await runFixedCommandWithTimeout([candidate, "status", "--json"], 5000);
|
||||
if (result.code !== 0) {
|
||||
continue;
|
||||
}
|
||||
const raw = result.stdout.trim();
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
const parsed = parsePossiblyNoisyJsonObject(raw);
|
||||
const self =
|
||||
typeof parsed.Self === "object" && parsed.Self !== null
|
||||
? (parsed.Self as Record<string, unknown>)
|
||||
: undefined;
|
||||
const dns = typeof self?.DNSName === "string" ? self.DNSName : undefined;
|
||||
if (dns && dns.length > 0) {
|
||||
return dns.replace(/\.$/, "");
|
||||
}
|
||||
const ips = Array.isArray(self?.TailscaleIPs) ? (self?.TailscaleIPs as string[]) : [];
|
||||
if (ips.length > 0) {
|
||||
return ips[0] ?? null;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsePossiblyNoisyJsonObject(raw: string): Record<string, unknown> {
|
||||
const start = raw.indexOf("{");
|
||||
const end = raw.lastIndexOf("}");
|
||||
if (start === -1 || end <= start) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw.slice(start, end + 1)) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
return await resolveTailnetHostWithRunner((argv, opts) =>
|
||||
runPluginCommandWithTimeout({
|
||||
argv,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAuth(cfg: OpenClawPluginApi["config"]): ResolveAuthResult {
|
||||
@@ -365,29 +263,16 @@ async function resolveGatewayUrl(api: OpenClawPluginApi): Promise<ResolveUrlResu
|
||||
}
|
||||
}
|
||||
|
||||
const bind = cfg.gateway?.bind ?? "loopback";
|
||||
if (bind === "custom") {
|
||||
const host = cfg.gateway?.customBindHost?.trim();
|
||||
if (host) {
|
||||
return { url: `${scheme}://${host}:${port}`, source: "gateway.bind=custom" };
|
||||
}
|
||||
return { error: "gateway.bind=custom requires gateway.customBindHost." };
|
||||
}
|
||||
|
||||
if (bind === "tailnet") {
|
||||
const host = pickTailnetIPv4();
|
||||
if (host) {
|
||||
return { url: `${scheme}://${host}:${port}`, source: "gateway.bind=tailnet" };
|
||||
}
|
||||
return { error: "gateway.bind=tailnet set, but no tailnet IP was found." };
|
||||
}
|
||||
|
||||
if (bind === "lan") {
|
||||
const host = pickLanIPv4();
|
||||
if (host) {
|
||||
return { url: `${scheme}://${host}:${port}`, source: "gateway.bind=lan" };
|
||||
}
|
||||
return { error: "gateway.bind=lan set, but no private LAN IP was found." };
|
||||
const bindResult = resolveGatewayBindUrl({
|
||||
bind: cfg.gateway?.bind,
|
||||
customBindHost: cfg.gateway?.customBindHost,
|
||||
scheme,
|
||||
port,
|
||||
pickTailnetHost: pickTailnetIPv4,
|
||||
pickLanHost: pickLanIPv4,
|
||||
});
|
||||
if (bindResult) {
|
||||
return bindResult;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-otel",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.25",
|
||||
"description": "OpenClaw diagnostics OpenTelemetry exporter",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.212.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.212.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.212.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.212.0",
|
||||
"@opentelemetry/exporter-logs-otlp-proto": "^0.212.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-proto": "^0.212.0",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.212.0",
|
||||
"@opentelemetry/resources": "^2.5.1",
|
||||
"@opentelemetry/sdk-logs": "^0.212.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.5.1",
|
||||
@@ -16,9 +16,6 @@
|
||||
"@opentelemetry/sdk-trace-base": "^2.5.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.39.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
@@ -51,11 +51,11 @@ vi.mock("@opentelemetry/sdk-node", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@opentelemetry/exporter-metrics-otlp-http", () => ({
|
||||
vi.mock("@opentelemetry/exporter-metrics-otlp-proto", () => ({
|
||||
OTLPMetricExporter: class {},
|
||||
}));
|
||||
|
||||
vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({
|
||||
vi.mock("@opentelemetry/exporter-trace-otlp-proto", () => ({
|
||||
OTLPTraceExporter: class {
|
||||
constructor(options?: unknown) {
|
||||
traceExporterCtor(options);
|
||||
@@ -63,7 +63,7 @@ vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({
|
||||
vi.mock("@opentelemetry/exporter-logs-otlp-proto", () => ({
|
||||
OTLPLogExporter: class {},
|
||||
}));
|
||||
|
||||
@@ -110,6 +110,10 @@ import type { OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
|
||||
import { emitDiagnosticEvent } from "openclaw/plugin-sdk";
|
||||
import { createDiagnosticsOtelService } from "./service.js";
|
||||
|
||||
const OTEL_TEST_STATE_DIR = "/tmp/openclaw-diagnostics-otel-test";
|
||||
const OTEL_TEST_ENDPOINT = "http://otel-collector:4318";
|
||||
const OTEL_TEST_PROTOCOL = "http/protobuf";
|
||||
|
||||
function createLogger() {
|
||||
return {
|
||||
info: vi.fn(),
|
||||
@@ -119,7 +123,15 @@ function createLogger() {
|
||||
};
|
||||
}
|
||||
|
||||
function createTraceOnlyContext(endpoint: string): OpenClawPluginServiceContext {
|
||||
type OtelContextFlags = {
|
||||
traces?: boolean;
|
||||
metrics?: boolean;
|
||||
logs?: boolean;
|
||||
};
|
||||
function createOtelContext(
|
||||
endpoint: string,
|
||||
{ traces = false, metrics = false, logs = false }: OtelContextFlags = {},
|
||||
): OpenClawPluginServiceContext {
|
||||
return {
|
||||
config: {
|
||||
diagnostics: {
|
||||
@@ -127,17 +139,46 @@ function createTraceOnlyContext(endpoint: string): OpenClawPluginServiceContext
|
||||
otel: {
|
||||
enabled: true,
|
||||
endpoint,
|
||||
protocol: "http/protobuf",
|
||||
traces: true,
|
||||
metrics: false,
|
||||
logs: false,
|
||||
protocol: OTEL_TEST_PROTOCOL,
|
||||
traces,
|
||||
metrics,
|
||||
logs,
|
||||
},
|
||||
},
|
||||
},
|
||||
logger: createLogger(),
|
||||
stateDir: "/tmp/openclaw-diagnostics-otel-test",
|
||||
stateDir: OTEL_TEST_STATE_DIR,
|
||||
};
|
||||
}
|
||||
|
||||
function createTraceOnlyContext(endpoint: string): OpenClawPluginServiceContext {
|
||||
return createOtelContext(endpoint, { traces: true });
|
||||
}
|
||||
|
||||
type RegisteredLogTransport = (logObj: Record<string, unknown>) => void;
|
||||
function setupRegisteredTransports() {
|
||||
const registeredTransports: RegisteredLogTransport[] = [];
|
||||
const stopTransport = vi.fn();
|
||||
registerLogTransportMock.mockImplementation((transport) => {
|
||||
registeredTransports.push(transport);
|
||||
return stopTransport;
|
||||
});
|
||||
return { registeredTransports, stopTransport };
|
||||
}
|
||||
|
||||
async function emitAndCaptureLog(logObj: Record<string, unknown>) {
|
||||
const { registeredTransports } = setupRegisteredTransports();
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { logs: true });
|
||||
await service.start(ctx);
|
||||
expect(registeredTransports).toHaveLength(1);
|
||||
registeredTransports[0]?.(logObj);
|
||||
expect(logEmit).toHaveBeenCalled();
|
||||
const emitCall = logEmit.mock.calls[0]?.[0];
|
||||
await service.stop?.(ctx);
|
||||
return emitCall;
|
||||
}
|
||||
|
||||
describe("diagnostics-otel service", () => {
|
||||
beforeEach(() => {
|
||||
telemetryState.counters.clear();
|
||||
@@ -154,31 +195,10 @@ describe("diagnostics-otel service", () => {
|
||||
});
|
||||
|
||||
test("records message-flow metrics and spans", async () => {
|
||||
const registeredTransports: Array<(logObj: Record<string, unknown>) => void> = [];
|
||||
const stopTransport = vi.fn();
|
||||
registerLogTransportMock.mockImplementation((transport) => {
|
||||
registeredTransports.push(transport);
|
||||
return stopTransport;
|
||||
});
|
||||
const { registeredTransports } = setupRegisteredTransports();
|
||||
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx: OpenClawPluginServiceContext = {
|
||||
config: {
|
||||
diagnostics: {
|
||||
enabled: true,
|
||||
otel: {
|
||||
enabled: true,
|
||||
endpoint: "http://otel-collector:4318",
|
||||
protocol: "http/protobuf",
|
||||
traces: true,
|
||||
metrics: true,
|
||||
logs: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
logger: createLogger(),
|
||||
stateDir: "/tmp/openclaw-diagnostics-otel-test",
|
||||
};
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true, logs: true });
|
||||
await service.start(ctx);
|
||||
|
||||
emitDiagnosticEvent({
|
||||
@@ -293,4 +313,55 @@ describe("diagnostics-otel service", () => {
|
||||
expect(options?.url).toBe("https://collector.example.com/v1/Traces");
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("redacts sensitive data from log messages before export", async () => {
|
||||
const emitCall = await emitAndCaptureLog({
|
||||
0: "Using API key sk-1234567890abcdef1234567890abcdef",
|
||||
_meta: { logLevelName: "INFO", date: new Date() },
|
||||
});
|
||||
|
||||
expect(emitCall?.body).not.toContain("sk-1234567890abcdef1234567890abcdef");
|
||||
expect(emitCall?.body).toContain("sk-123");
|
||||
expect(emitCall?.body).toContain("…");
|
||||
});
|
||||
|
||||
test("redacts sensitive data from log attributes before export", async () => {
|
||||
const emitCall = await emitAndCaptureLog({
|
||||
0: '{"token":"ghp_abcdefghijklmnopqrstuvwxyz123456"}',
|
||||
1: "auth configured",
|
||||
_meta: { logLevelName: "DEBUG", date: new Date() },
|
||||
});
|
||||
|
||||
const tokenAttr = emitCall?.attributes?.["openclaw.token"];
|
||||
expect(tokenAttr).not.toBe("ghp_abcdefghijklmnopqrstuvwxyz123456");
|
||||
if (typeof tokenAttr === "string") {
|
||||
expect(tokenAttr).toContain("…");
|
||||
}
|
||||
});
|
||||
|
||||
test("redacts sensitive reason in session.state metric attributes", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { metrics: true });
|
||||
await service.start(ctx);
|
||||
|
||||
emitDiagnosticEvent({
|
||||
type: "session.state",
|
||||
state: "waiting",
|
||||
reason: "token=ghp_abcdefghijklmnopqrstuvwxyz123456",
|
||||
});
|
||||
|
||||
const sessionCounter = telemetryState.counters.get("openclaw.session.state");
|
||||
expect(sessionCounter?.add).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
"openclaw.reason": expect.stringContaining("…"),
|
||||
}),
|
||||
);
|
||||
const attrs = sessionCounter?.add.mock.calls[0]?.[1] as Record<string, unknown> | undefined;
|
||||
expect(typeof attrs?.["openclaw.reason"]).toBe("string");
|
||||
expect(String(attrs?.["openclaw.reason"])).not.toContain(
|
||||
"ghp_abcdefghijklmnopqrstuvwxyz123456",
|
||||
);
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { metrics, trace, SpanStatusCode } from "@opentelemetry/api";
|
||||
import type { SeverityNumber } from "@opentelemetry/api-logs";
|
||||
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
|
||||
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
||||
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
|
||||
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto";
|
||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
|
||||
import { resourceFromAttributes } from "@opentelemetry/resources";
|
||||
import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
|
||||
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
||||
@@ -10,7 +10,7 @@ import { NodeSDK } from "@opentelemetry/sdk-node";
|
||||
import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
|
||||
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
||||
import type { DiagnosticEventPayload, OpenClawPluginService } from "openclaw/plugin-sdk";
|
||||
import { onDiagnosticEvent, registerLogTransport } from "openclaw/plugin-sdk";
|
||||
import { onDiagnosticEvent, redactSensitiveText, registerLogTransport } from "openclaw/plugin-sdk";
|
||||
|
||||
const DEFAULT_SERVICE_NAME = "openclaw";
|
||||
|
||||
@@ -54,6 +54,14 @@ function formatError(err: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
function redactOtelAttributes(attributes: Record<string, string | number | boolean>) {
|
||||
const redactedAttributes: Record<string, string | number | boolean> = {};
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
redactedAttributes[key] = typeof value === "string" ? redactSensitiveText(value) : value;
|
||||
}
|
||||
return redactedAttributes;
|
||||
}
|
||||
|
||||
export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
let sdk: NodeSDK | null = null;
|
||||
let logProvider: LoggerProvider | null = null;
|
||||
@@ -336,11 +344,12 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
attributes["openclaw.code.location"] = meta.path.filePathWithLine;
|
||||
}
|
||||
|
||||
// OTLP can leave the host boundary, so redact string fields before export.
|
||||
otelLogger.emit({
|
||||
body: message,
|
||||
body: redactSensitiveText(message),
|
||||
severityText: logLevelName,
|
||||
severityNumber,
|
||||
attributes,
|
||||
attributes: redactOtelAttributes(attributes),
|
||||
timestamp: meta?.date ?? new Date(),
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -469,9 +478,10 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
if (!tracesEnabled) {
|
||||
return;
|
||||
}
|
||||
const redactedError = redactSensitiveText(evt.error);
|
||||
const spanAttrs: Record<string, string | number> = {
|
||||
...attrs,
|
||||
"openclaw.error": evt.error,
|
||||
"openclaw.error": redactedError,
|
||||
};
|
||||
if (evt.chatId !== undefined) {
|
||||
spanAttrs["openclaw.chatId"] = String(evt.chatId);
|
||||
@@ -479,7 +489,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
const span = tracer.startSpan("openclaw.webhook.error", {
|
||||
attributes: spanAttrs,
|
||||
});
|
||||
span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error });
|
||||
span.setStatus({ code: SpanStatusCode.ERROR, message: redactedError });
|
||||
span.end();
|
||||
};
|
||||
|
||||
@@ -496,6 +506,18 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
}
|
||||
};
|
||||
|
||||
const addSessionIdentityAttrs = (
|
||||
spanAttrs: Record<string, string | number>,
|
||||
evt: { sessionKey?: string; sessionId?: string },
|
||||
) => {
|
||||
if (evt.sessionKey) {
|
||||
spanAttrs["openclaw.sessionKey"] = evt.sessionKey;
|
||||
}
|
||||
if (evt.sessionId) {
|
||||
spanAttrs["openclaw.sessionId"] = evt.sessionId;
|
||||
}
|
||||
};
|
||||
|
||||
const recordMessageProcessed = (
|
||||
evt: Extract<DiagnosticEventPayload, { type: "message.processed" }>,
|
||||
) => {
|
||||
@@ -511,12 +533,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
return;
|
||||
}
|
||||
const spanAttrs: Record<string, string | number> = { ...attrs };
|
||||
if (evt.sessionKey) {
|
||||
spanAttrs["openclaw.sessionKey"] = evt.sessionKey;
|
||||
}
|
||||
if (evt.sessionId) {
|
||||
spanAttrs["openclaw.sessionId"] = evt.sessionId;
|
||||
}
|
||||
addSessionIdentityAttrs(spanAttrs, evt);
|
||||
if (evt.chatId !== undefined) {
|
||||
spanAttrs["openclaw.chatId"] = String(evt.chatId);
|
||||
}
|
||||
@@ -524,11 +541,11 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
spanAttrs["openclaw.messageId"] = String(evt.messageId);
|
||||
}
|
||||
if (evt.reason) {
|
||||
spanAttrs["openclaw.reason"] = evt.reason;
|
||||
spanAttrs["openclaw.reason"] = redactSensitiveText(evt.reason);
|
||||
}
|
||||
const span = spanWithDuration("openclaw.message.processed", spanAttrs, evt.durationMs);
|
||||
if (evt.outcome === "error") {
|
||||
span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error });
|
||||
if (evt.outcome === "error" && evt.error) {
|
||||
span.setStatus({ code: SpanStatusCode.ERROR, message: redactSensitiveText(evt.error) });
|
||||
}
|
||||
span.end();
|
||||
};
|
||||
@@ -557,7 +574,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
) => {
|
||||
const attrs: Record<string, string> = { "openclaw.state": evt.state };
|
||||
if (evt.reason) {
|
||||
attrs["openclaw.reason"] = evt.reason;
|
||||
attrs["openclaw.reason"] = redactSensitiveText(evt.reason);
|
||||
}
|
||||
sessionStateCounter.add(1, attrs);
|
||||
};
|
||||
@@ -574,12 +591,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
return;
|
||||
}
|
||||
const spanAttrs: Record<string, string | number> = { ...attrs };
|
||||
if (evt.sessionKey) {
|
||||
spanAttrs["openclaw.sessionKey"] = evt.sessionKey;
|
||||
}
|
||||
if (evt.sessionId) {
|
||||
spanAttrs["openclaw.sessionId"] = evt.sessionId;
|
||||
}
|
||||
addSessionIdentityAttrs(spanAttrs, evt);
|
||||
spanAttrs["openclaw.queueDepth"] = evt.queueDepth ?? 0;
|
||||
spanAttrs["openclaw.ageMs"] = evt.ageMs;
|
||||
const span = tracer.startSpan("openclaw.session.stuck", { attributes: spanAttrs });
|
||||
@@ -645,7 +657,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
});
|
||||
|
||||
if (logsEnabled) {
|
||||
ctx.logger.info("diagnostics-otel: logs exporter enabled (OTLP/HTTP)");
|
||||
ctx.logger.info("diagnostics-otel: logs exporter enabled (OTLP/Protobuf)");
|
||||
}
|
||||
},
|
||||
async stop() {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
{
|
||||
"name": "@openclaw/discord",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.25",
|
||||
"description": "OpenClaw Discord channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
buildTokenChannelStatusSummary,
|
||||
collectDiscordAuditChannelIds,
|
||||
collectDiscordStatusIssues,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
@@ -347,16 +348,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
lastError: null,
|
||||
},
|
||||
collectStatusIssues: collectDiscordStatusIssues,
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
tokenSource: snapshot.tokenSource ?? "none",
|
||||
running: snapshot.running ?? false,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
buildChannelSummary: ({ snapshot }) =>
|
||||
buildTokenChannelStatusSummary(snapshot, { includeMode: false }),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, {
|
||||
includeApplication: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/feishu",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.25",
|
||||
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -8,9 +8,6 @@
|
||||
"@sinclair/typebox": "0.34.48",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
@@ -720,10 +720,10 @@ export async function handleFeishuMessage(params: {
|
||||
// When topicSessionMode is enabled, messages within a topic (identified by root_id)
|
||||
// get a separate session from the main group chat.
|
||||
let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
|
||||
let topicSessionMode: "enabled" | "disabled" = "disabled";
|
||||
if (isGroup && ctx.rootId) {
|
||||
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
|
||||
const topicSessionMode =
|
||||
groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
|
||||
topicSessionMode = groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
|
||||
if (topicSessionMode === "enabled") {
|
||||
// Use chatId:topic:rootId as peer ID for topic-scoped sessions
|
||||
peerId = `${ctx.chatId}:topic:${ctx.rootId}`;
|
||||
@@ -739,6 +739,14 @@ export async function handleFeishuMessage(params: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: peerId,
|
||||
},
|
||||
// Add parentPeer for binding inheritance in topic mode
|
||||
parentPeer:
|
||||
isGroup && ctx.rootId && topicSessionMode === "enabled"
|
||||
? {
|
||||
kind: "group",
|
||||
id: ctx.chatId,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
// Dynamic agent creation for DM users
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../../../src/infra/tmp-openclaw-dir.js";
|
||||
|
||||
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
||||
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
|
||||
@@ -42,7 +42,7 @@ function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
|
||||
expect(pathValue).not.toContain(key);
|
||||
expect(pathValue).not.toContain("..");
|
||||
|
||||
const tmpRoot = path.resolve(os.tmpdir());
|
||||
const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir());
|
||||
const resolved = path.resolve(pathValue);
|
||||
const rel = path.relative(tmpRoot, resolved);
|
||||
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createFeishuClient } from "./client.js";
|
||||
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
|
||||
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
|
||||
import { resolveFeishuSendTarget } from "./send-target.js";
|
||||
|
||||
export type DownloadImageResult = {
|
||||
buffer: Buffer;
|
||||
@@ -268,18 +268,11 @@ export async function sendImageFeishu(params: {
|
||||
accountId?: string;
|
||||
}): Promise<SendMediaResult> {
|
||||
const { cfg, to, imageKey, replyToMessageId, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
const receiveId = normalizeFeishuTarget(to);
|
||||
if (!receiveId) {
|
||||
throw new Error(`Invalid Feishu target: ${to}`);
|
||||
}
|
||||
|
||||
const receiveIdType = resolveReceiveIdType(receiveId);
|
||||
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({
|
||||
cfg,
|
||||
to,
|
||||
accountId,
|
||||
});
|
||||
const content = JSON.stringify({ image_key: imageKey });
|
||||
|
||||
if (replyToMessageId) {
|
||||
@@ -320,18 +313,11 @@ export async function sendFileFeishu(params: {
|
||||
}): Promise<SendMediaResult> {
|
||||
const { cfg, to, fileKey, replyToMessageId, accountId } = params;
|
||||
const msgType = params.msgType ?? "file";
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
const receiveId = normalizeFeishuTarget(to);
|
||||
if (!receiveId) {
|
||||
throw new Error(`Invalid Feishu target: ${to}`);
|
||||
}
|
||||
|
||||
const receiveIdType = resolveReceiveIdType(receiveId);
|
||||
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({
|
||||
cfg,
|
||||
to,
|
||||
accountId,
|
||||
});
|
||||
const content = JSON.stringify({ file_key: fileKey });
|
||||
|
||||
if (replyToMessageId) {
|
||||
|
||||
25
extensions/feishu/src/send-target.ts
Normal file
25
extensions/feishu/src/send-target.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
|
||||
|
||||
export function resolveFeishuSendTarget(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
to: string;
|
||||
accountId?: string;
|
||||
}) {
|
||||
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
const client = createFeishuClient(account);
|
||||
const receiveId = normalizeFeishuTarget(params.to);
|
||||
if (!receiveId) {
|
||||
throw new Error(`Invalid Feishu target: ${params.to}`);
|
||||
}
|
||||
return {
|
||||
client,
|
||||
receiveId,
|
||||
receiveIdType: resolveReceiveIdType(receiveId),
|
||||
};
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import type { MentionTarget } from "./mention.js";
|
||||
import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
|
||||
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
|
||||
import type { FeishuSendResult, ResolvedFeishuAccount } from "./types.js";
|
||||
import { resolveFeishuSendTarget } from "./send-target.js";
|
||||
import type { FeishuSendResult } from "./types.js";
|
||||
|
||||
export type FeishuMessageInfo = {
|
||||
messageId: string;
|
||||
@@ -128,18 +128,7 @@ export async function sendMessageFeishu(
|
||||
params: SendFeishuMessageParams,
|
||||
): Promise<FeishuSendResult> {
|
||||
const { cfg, to, text, replyToMessageId, mentions, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
const receiveId = normalizeFeishuTarget(to);
|
||||
if (!receiveId) {
|
||||
throw new Error(`Invalid Feishu target: ${to}`);
|
||||
}
|
||||
|
||||
const receiveIdType = resolveReceiveIdType(receiveId);
|
||||
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
|
||||
const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
@@ -188,18 +177,7 @@ export type SendFeishuCardParams = {
|
||||
|
||||
export async function sendCardFeishu(params: SendFeishuCardParams): Promise<FeishuSendResult> {
|
||||
const { cfg, to, card, replyToMessageId, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
const receiveId = normalizeFeishuTarget(to);
|
||||
if (!receiveId) {
|
||||
throw new Error(`Invalid Feishu target: ${to}`);
|
||||
}
|
||||
|
||||
const receiveIdType = resolveReceiveIdType(receiveId);
|
||||
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
|
||||
const content = JSON.stringify(card);
|
||||
|
||||
if (replyToMessageId) {
|
||||
|
||||
@@ -132,6 +132,26 @@ export class FeishuStreamingSession {
|
||||
this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`);
|
||||
}
|
||||
|
||||
private async updateCardContent(text: string, onError?: (error: unknown) => void): Promise<void> {
|
||||
if (!this.state) {
|
||||
return;
|
||||
}
|
||||
const apiBase = resolveApiBase(this.creds.domain);
|
||||
this.state.sequence += 1;
|
||||
await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getToken(this.creds)}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: text,
|
||||
sequence: this.state.sequence,
|
||||
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
|
||||
}),
|
||||
}).catch((error) => onError?.(error));
|
||||
}
|
||||
|
||||
async update(text: string): Promise<void> {
|
||||
if (!this.state || this.closed) {
|
||||
return;
|
||||
@@ -150,20 +170,7 @@ export class FeishuStreamingSession {
|
||||
return;
|
||||
}
|
||||
this.state.currentText = text;
|
||||
this.state.sequence += 1;
|
||||
const apiBase = resolveApiBase(this.creds.domain);
|
||||
await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getToken(this.creds)}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: text,
|
||||
sequence: this.state.sequence,
|
||||
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
|
||||
}),
|
||||
}).catch((e) => this.log?.(`Update failed: ${String(e)}`));
|
||||
await this.updateCardContent(text, (e) => this.log?.(`Update failed: ${String(e)}`));
|
||||
});
|
||||
await this.queue;
|
||||
}
|
||||
@@ -181,19 +188,7 @@ export class FeishuStreamingSession {
|
||||
|
||||
// Only send final update if content differs from what's already displayed
|
||||
if (text && text !== this.state.currentText) {
|
||||
this.state.sequence += 1;
|
||||
await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getToken(this.creds)}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: text,
|
||||
sequence: this.state.sequence,
|
||||
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
await this.updateCardContent(text);
|
||||
this.state.currentText = text;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# Google Antigravity Auth (OpenClaw plugin)
|
||||
|
||||
OAuth provider plugin for **Google Antigravity** (Cloud Code Assist).
|
||||
|
||||
## Enable
|
||||
|
||||
Bundled plugins are disabled by default. Enable this one:
|
||||
|
||||
```bash
|
||||
openclaw plugins enable google-antigravity-auth
|
||||
```
|
||||
|
||||
Restart the Gateway after enabling.
|
||||
|
||||
## Authenticate
|
||||
|
||||
```bash
|
||||
openclaw models auth login --provider google-antigravity --set-default
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Antigravity uses Google Cloud project quotas.
|
||||
- If requests fail, ensure Gemini for Google Cloud is enabled.
|
||||
@@ -1,424 +0,0 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import {
|
||||
buildOauthProviderAuthResult,
|
||||
emptyPluginConfigSchema,
|
||||
isWSL2Sync,
|
||||
type OpenClawPluginApi,
|
||||
type ProviderAuthContext,
|
||||
} from "openclaw/plugin-sdk";
|
||||
|
||||
// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync
|
||||
const decode = (s: string) => Buffer.from(s, "base64").toString();
|
||||
const CLIENT_ID = decode(
|
||||
"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
|
||||
);
|
||||
const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
|
||||
const REDIRECT_URI = "http://localhost:51121/oauth-callback";
|
||||
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
|
||||
const DEFAULT_MODEL = "google-antigravity/claude-opus-4-6-thinking";
|
||||
|
||||
const SCOPES = [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/cclog",
|
||||
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||
];
|
||||
|
||||
const CODE_ASSIST_ENDPOINTS = [
|
||||
"https://cloudcode-pa.googleapis.com",
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
||||
];
|
||||
|
||||
const RESPONSE_PAGE = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>OpenClaw Antigravity OAuth</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Authentication complete</h1>
|
||||
<p>You can return to the terminal.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
function generatePkce(): { verifier: string; challenge: string } {
|
||||
const verifier = randomBytes(32).toString("hex");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
|
||||
return isRemote || isWSL2Sync();
|
||||
}
|
||||
|
||||
function buildAuthUrl(params: { challenge: string; state: string }): string {
|
||||
const url = new URL(AUTH_URL);
|
||||
url.searchParams.set("client_id", CLIENT_ID);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
||||
url.searchParams.set("scope", SCOPES.join(" "));
|
||||
url.searchParams.set("code_challenge", params.challenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
url.searchParams.set("state", params.state);
|
||||
url.searchParams.set("access_type", "offline");
|
||||
url.searchParams.set("prompt", "consent");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function parseCallbackInput(input: string): { code: string; state: string } | { error: string } {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return { error: "No input provided" };
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
if (!code) {
|
||||
return { error: "Missing 'code' parameter in URL" };
|
||||
}
|
||||
if (!state) {
|
||||
return { error: "Missing 'state' parameter in URL" };
|
||||
}
|
||||
return { code, state };
|
||||
} catch {
|
||||
return { error: "Paste the full redirect URL (not just the code)." };
|
||||
}
|
||||
}
|
||||
|
||||
async function startCallbackServer(params: { timeoutMs: number }) {
|
||||
const redirect = new URL(REDIRECT_URI);
|
||||
const port = redirect.port ? Number(redirect.port) : 51121;
|
||||
|
||||
let settled = false;
|
||||
let resolveCallback: (url: URL) => void;
|
||||
let rejectCallback: (err: Error) => void;
|
||||
|
||||
const callbackPromise = new Promise<URL>((resolve, reject) => {
|
||||
resolveCallback = (url) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve(url);
|
||||
};
|
||||
rejectCallback = (err) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
reject(err);
|
||||
};
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
rejectCallback(new Error("Timed out waiting for OAuth callback"));
|
||||
}, params.timeoutMs);
|
||||
timeout.unref?.();
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
if (!request.url) {
|
||||
response.writeHead(400, { "Content-Type": "text/plain" });
|
||||
response.end("Missing URL");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(request.url, `${redirect.protocol}//${redirect.host}`);
|
||||
if (url.pathname !== redirect.pathname) {
|
||||
response.writeHead(404, { "Content-Type": "text/plain" });
|
||||
response.end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
response.end(RESPONSE_PAGE);
|
||||
resolveCallback(url);
|
||||
|
||||
setImmediate(() => {
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => {
|
||||
server.off("error", onError);
|
||||
reject(err);
|
||||
};
|
||||
server.once("error", onError);
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.off("error", onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
waitForCallback: () => callbackPromise,
|
||||
close: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function exchangeCode(params: {
|
||||
code: string;
|
||||
verifier: string;
|
||||
}): Promise<{ access: string; refresh: string; expires: number }> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
code: params.code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: params.verifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Token exchange failed: ${text}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
|
||||
const access = data.access_token?.trim();
|
||||
const refresh = data.refresh_token?.trim();
|
||||
const expiresIn = data.expires_in ?? 0;
|
||||
|
||||
if (!access) {
|
||||
throw new Error("Token exchange returned no access_token");
|
||||
}
|
||||
if (!refresh) {
|
||||
throw new Error("Token exchange returned no refresh_token");
|
||||
}
|
||||
|
||||
const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000;
|
||||
return { access, refresh, expires };
|
||||
}
|
||||
|
||||
async function fetchUserEmail(accessToken: string): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return undefined;
|
||||
}
|
||||
const data = (await response.json()) as { email?: string };
|
||||
return data.email;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProjectId(accessToken: string): Promise<string> {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
||||
"Client-Metadata": JSON.stringify({
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
}),
|
||||
};
|
||||
|
||||
for (const endpoint of CODE_ASSIST_ENDPOINTS) {
|
||||
try {
|
||||
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
continue;
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
cloudaicompanionProject?: string | { id?: string };
|
||||
};
|
||||
|
||||
if (typeof data.cloudaicompanionProject === "string") {
|
||||
return data.cloudaicompanionProject;
|
||||
}
|
||||
if (
|
||||
data.cloudaicompanionProject &&
|
||||
typeof data.cloudaicompanionProject === "object" &&
|
||||
data.cloudaicompanionProject.id
|
||||
) {
|
||||
return data.cloudaicompanionProject.id;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_PROJECT_ID;
|
||||
}
|
||||
|
||||
async function loginAntigravity(params: {
|
||||
isRemote: boolean;
|
||||
openUrl: (url: string) => Promise<void>;
|
||||
prompt: (message: string) => Promise<string>;
|
||||
note: (message: string, title?: string) => Promise<void>;
|
||||
log: (message: string) => void;
|
||||
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
|
||||
}): Promise<{
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
email?: string;
|
||||
projectId: string;
|
||||
}> {
|
||||
const { verifier, challenge } = generatePkce();
|
||||
const state = randomBytes(16).toString("hex");
|
||||
const authUrl = buildAuthUrl({ challenge, state });
|
||||
|
||||
let callbackServer: Awaited<ReturnType<typeof startCallbackServer>> | null = null;
|
||||
const needsManual = shouldUseManualOAuthFlow(params.isRemote);
|
||||
if (!needsManual) {
|
||||
try {
|
||||
callbackServer = await startCallbackServer({ timeoutMs: 5 * 60 * 1000 });
|
||||
} catch {
|
||||
callbackServer = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!callbackServer) {
|
||||
await params.note(
|
||||
[
|
||||
"Open the URL in your local browser.",
|
||||
"After signing in, copy the full redirect URL and paste it back here.",
|
||||
"",
|
||||
`Auth URL: ${authUrl}`,
|
||||
`Redirect URI: ${REDIRECT_URI}`,
|
||||
].join("\n"),
|
||||
"Google Antigravity OAuth",
|
||||
);
|
||||
// Output raw URL below the box for easy copying (fixes #1772)
|
||||
params.log("");
|
||||
params.log("Copy this URL:");
|
||||
params.log(authUrl);
|
||||
params.log("");
|
||||
}
|
||||
|
||||
if (!needsManual) {
|
||||
params.progress.update("Opening Google sign-in…");
|
||||
try {
|
||||
await params.openUrl(authUrl);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
let code = "";
|
||||
let returnedState = "";
|
||||
|
||||
if (callbackServer) {
|
||||
params.progress.update("Waiting for OAuth callback…");
|
||||
const callback = await callbackServer.waitForCallback();
|
||||
code = callback.searchParams.get("code") ?? "";
|
||||
returnedState = callback.searchParams.get("state") ?? "";
|
||||
await callbackServer.close();
|
||||
} else {
|
||||
params.progress.update("Waiting for redirect URL…");
|
||||
const input = await params.prompt("Paste the redirect URL: ");
|
||||
const parsed = parseCallbackInput(input);
|
||||
if ("error" in parsed) {
|
||||
throw new Error(parsed.error);
|
||||
}
|
||||
code = parsed.code;
|
||||
returnedState = parsed.state;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
throw new Error("Missing OAuth code");
|
||||
}
|
||||
if (returnedState !== state) {
|
||||
throw new Error("OAuth state mismatch. Please try again.");
|
||||
}
|
||||
|
||||
params.progress.update("Exchanging code for tokens…");
|
||||
const tokens = await exchangeCode({ code, verifier });
|
||||
const email = await fetchUserEmail(tokens.access);
|
||||
const projectId = await fetchProjectId(tokens.access);
|
||||
|
||||
params.progress.stop("Antigravity OAuth complete");
|
||||
return { ...tokens, email, projectId };
|
||||
}
|
||||
|
||||
const antigravityPlugin = {
|
||||
id: "google-antigravity-auth",
|
||||
name: "Google Antigravity Auth",
|
||||
description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerProvider({
|
||||
id: "google-antigravity",
|
||||
label: "Google Antigravity",
|
||||
docsPath: "/providers/models",
|
||||
aliases: ["antigravity"],
|
||||
auth: [
|
||||
{
|
||||
id: "oauth",
|
||||
label: "Google OAuth",
|
||||
hint: "PKCE + localhost callback",
|
||||
kind: "oauth",
|
||||
run: async (ctx: ProviderAuthContext) => {
|
||||
const spin = ctx.prompter.progress("Starting Antigravity OAuth…");
|
||||
try {
|
||||
const result = await loginAntigravity({
|
||||
isRemote: ctx.isRemote,
|
||||
openUrl: ctx.openUrl,
|
||||
prompt: async (message) => String(await ctx.prompter.text({ message })),
|
||||
note: ctx.prompter.note,
|
||||
log: (message) => ctx.runtime.log(message),
|
||||
progress: spin,
|
||||
});
|
||||
|
||||
return buildOauthProviderAuthResult({
|
||||
providerId: "google-antigravity",
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
email: result.email,
|
||||
credentialExtra: { projectId: result.projectId },
|
||||
notes: [
|
||||
"Antigravity uses Google Cloud project quotas.",
|
||||
"Enable Gemini for Google Cloud on your project if requests fail.",
|
||||
],
|
||||
});
|
||||
} catch (err) {
|
||||
spin.stop("Antigravity OAuth failed");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default antigravityPlugin;
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"id": "google-antigravity-auth",
|
||||
"providers": ["google-antigravity"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "@openclaw/google-antigravity-auth",
|
||||
"version": "2026.2.22",
|
||||
"private": true,
|
||||
"description": "OpenClaw Google Antigravity OAuth provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
{
|
||||
"name": "@openclaw/google-gemini-cli-auth",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.25",
|
||||
"private": true,
|
||||
"description": "OpenClaw Gemini CLI OAuth provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
{
|
||||
"name": "@openclaw/googlechat",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.25",
|
||||
"private": true,
|
||||
"description": "OpenClaw Google Chat channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"google-auth-library": "^10.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
"google-auth-library": "^10.6.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.1.26"
|
||||
|
||||
@@ -2,8 +2,9 @@ import { describe, expect, it } from "vitest";
|
||||
import { isSenderAllowed } from "./monitor.js";
|
||||
|
||||
describe("isSenderAllowed", () => {
|
||||
it("matches allowlist entries with raw email", () => {
|
||||
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(true);
|
||||
it("matches raw email entries only when dangerous name matching is enabled", () => {
|
||||
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(false);
|
||||
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"], true)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat users/<email> entries as email allowlist (deprecated form)", () => {
|
||||
@@ -17,6 +18,8 @@ describe("isSenderAllowed", () => {
|
||||
});
|
||||
|
||||
it("rejects non-matching raw email entries", () => {
|
||||
expect(isSenderAllowed("users/123", "jane@example.com", ["other@example.com"])).toBe(false);
|
||||
expect(isSenderAllowed("users/123", "jane@example.com", ["other@example.com"], true)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
readJsonBodyWithLimit,
|
||||
registerWebhookTarget,
|
||||
rejectNonPostWebhookRequest,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveSingleWebhookTargetAsync,
|
||||
@@ -287,6 +288,7 @@ export function isSenderAllowed(
|
||||
senderId: string,
|
||||
senderEmail: string | undefined,
|
||||
allowFrom: string[],
|
||||
allowNameMatching = false,
|
||||
) {
|
||||
if (allowFrom.includes("*")) {
|
||||
return true;
|
||||
@@ -305,8 +307,8 @@ export function isSenderAllowed(
|
||||
return normalizeUserId(withoutPrefix) === normalizedSenderId;
|
||||
}
|
||||
|
||||
// Raw email allowlist entries remain supported for usability.
|
||||
if (normalizedEmail && isEmailLike(withoutPrefix)) {
|
||||
// Raw email allowlist entries are a break-glass override.
|
||||
if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) {
|
||||
return withoutPrefix === normalizedEmail;
|
||||
}
|
||||
|
||||
@@ -409,6 +411,7 @@ async function processMessageWithPipeline(params: {
|
||||
const senderId = sender?.name ?? "";
|
||||
const senderName = sender?.displayName ?? "";
|
||||
const senderEmail = sender?.email ?? undefined;
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
|
||||
|
||||
const allowBots = account.config.allowBots === true;
|
||||
if (!allowBots) {
|
||||
@@ -489,6 +492,7 @@ async function processMessageWithPipeline(params: {
|
||||
senderId,
|
||||
senderEmail,
|
||||
groupUsers.map((v) => String(v)),
|
||||
allowNameMatching,
|
||||
);
|
||||
if (!ok) {
|
||||
logVerbose(core, runtime, `drop group message (sender not allowed, ${senderId})`);
|
||||
@@ -508,7 +512,12 @@ async function processMessageWithPipeline(params: {
|
||||
warnDeprecatedUsersEmailEntries(core, runtime, effectiveAllowFrom);
|
||||
const commandAllowFrom = isGroup ? groupUsers.map((v) => String(v)) : effectiveAllowFrom;
|
||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||
const senderAllowedForCommands = isSenderAllowed(senderId, senderEmail, commandAllowFrom);
|
||||
const senderAllowedForCommands = isSenderAllowed(
|
||||
senderId,
|
||||
senderEmail,
|
||||
commandAllowFrom,
|
||||
allowNameMatching,
|
||||
);
|
||||
const commandAuthorized = shouldComputeAuth
|
||||
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
{
|
||||
"name": "@openclaw/imessage",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.25",
|
||||
"private": true,
|
||||
"description": "OpenClaw iMessage channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
{
|
||||
"name": "@openclaw/irc",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.25",
|
||||
"description": "OpenClaw IRC channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import {
|
||||
buildBaseAccountStatusSnapshot,
|
||||
buildBaseChannelStatusSummary,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
getChatChannelMeta,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
deleteAccountFromConfigSection,
|
||||
type ChannelPlugin,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import {
|
||||
@@ -319,37 +321,23 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
|
||||
lastError: null,
|
||||
},
|
||||
buildChannelSummary: ({ account, snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
...buildBaseChannelStatusSummary(snapshot),
|
||||
host: account.host,
|
||||
port: snapshot.port,
|
||||
tls: account.tls,
|
||||
nick: account.nick,
|
||||
running: snapshot.running ?? false,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ cfg, account, timeoutMs }) =>
|
||||
probeIrc(cfg as CoreConfig, { accountId: account.accountId, timeoutMs }),
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
...buildBaseAccountStatusSnapshot({ account, runtime, probe }),
|
||||
host: account.host,
|
||||
port: account.port,
|
||||
tls: account.tls,
|
||||
nick: account.nick,
|
||||
passwordSource: account.passwordSource,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
probe,
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
}),
|
||||
},
|
||||
gateway: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
MarkdownConfigSchema,
|
||||
ReplyRuntimeConfigSchemaShape,
|
||||
ToolPolicySchema,
|
||||
requireOpenAllowFrom,
|
||||
} from "openclaw/plugin-sdk";
|
||||
@@ -45,6 +46,7 @@ export const IrcAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
dangerouslyAllowNameMatching: z.boolean().optional(),
|
||||
host: z.string().optional(),
|
||||
port: z.number().int().min(1).max(65535).optional(),
|
||||
tls: z.boolean().optional(),
|
||||
@@ -62,15 +64,7 @@ export const IrcAccountSchemaBase = z
|
||||
channels: z.array(z.string()).optional(),
|
||||
mentionPatterns: z.array(z.string()).optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
...ReplyRuntimeConfigSchemaShape,
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
34
extensions/irc/src/inbound.policy.test.ts
Normal file
34
extensions/irc/src/inbound.policy.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __testing } from "./inbound.js";
|
||||
|
||||
describe("irc inbound policy", () => {
|
||||
it("keeps DM allowlist merged with pairing-store entries", () => {
|
||||
const resolved = __testing.resolveIrcEffectiveAllowlists({
|
||||
configAllowFrom: ["owner"],
|
||||
configGroupAllowFrom: [],
|
||||
storeAllowList: ["paired-user"],
|
||||
});
|
||||
|
||||
expect(resolved.effectiveAllowFrom).toEqual(["owner", "paired-user"]);
|
||||
});
|
||||
|
||||
it("does not grant group access from pairing-store when explicit groupAllowFrom exists", () => {
|
||||
const resolved = __testing.resolveIrcEffectiveAllowlists({
|
||||
configAllowFrom: ["owner"],
|
||||
configGroupAllowFrom: ["group-owner"],
|
||||
storeAllowList: ["paired-user"],
|
||||
});
|
||||
|
||||
expect(resolved.effectiveGroupAllowFrom).toEqual(["group-owner"]);
|
||||
});
|
||||
|
||||
it("does not grant group access from pairing-store when groupAllowFrom is empty", () => {
|
||||
const resolved = __testing.resolveIrcEffectiveAllowlists({
|
||||
configAllowFrom: ["owner"],
|
||||
configGroupAllowFrom: [],
|
||||
storeAllowList: ["paired-user"],
|
||||
});
|
||||
|
||||
expect(resolved.effectiveGroupAllowFrom).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,16 @@
|
||||
import {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
createNormalizedOutboundDeliverer,
|
||||
createReplyPrefixOptions,
|
||||
formatTextWithAttachmentLinks,
|
||||
logInboundDrop,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveControlCommandGate,
|
||||
resolveOutboundMediaUrls,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
type OutboundReplyPayload,
|
||||
type OpenClawConfig,
|
||||
type RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk";
|
||||
@@ -26,33 +31,35 @@ const CHANNEL_ID = "irc" as const;
|
||||
|
||||
const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
function resolveIrcEffectiveAllowlists(params: {
|
||||
configAllowFrom: string[];
|
||||
configGroupAllowFrom: string[];
|
||||
storeAllowList: string[];
|
||||
}): {
|
||||
effectiveAllowFrom: string[];
|
||||
effectiveGroupAllowFrom: string[];
|
||||
} {
|
||||
const effectiveAllowFrom = [...params.configAllowFrom, ...params.storeAllowList].filter(Boolean);
|
||||
// Pairing-store entries are DM approvals and must not widen group sender authorization.
|
||||
const effectiveGroupAllowFrom = [...params.configGroupAllowFrom].filter(Boolean);
|
||||
return { effectiveAllowFrom, effectiveGroupAllowFrom };
|
||||
}
|
||||
|
||||
async function deliverIrcReply(params: {
|
||||
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string };
|
||||
payload: OutboundReplyPayload;
|
||||
target: string;
|
||||
accountId: string;
|
||||
sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>;
|
||||
statusSink?: (patch: { lastOutboundAt?: number }) => void;
|
||||
}) {
|
||||
const text = params.payload.text ?? "";
|
||||
const mediaList = params.payload.mediaUrls?.length
|
||||
? params.payload.mediaUrls
|
||||
: params.payload.mediaUrl
|
||||
? [params.payload.mediaUrl]
|
||||
: [];
|
||||
|
||||
if (!text.trim() && mediaList.length === 0) {
|
||||
const combined = formatTextWithAttachmentLinks(
|
||||
params.payload.text,
|
||||
resolveOutboundMediaUrls(params.payload),
|
||||
);
|
||||
if (!combined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaBlock = mediaList.length
|
||||
? mediaList.map((url) => `Attachment: ${url}`).join("\n")
|
||||
: "";
|
||||
const combined = text.trim()
|
||||
? mediaBlock
|
||||
? `${text.trim()}\n\n${mediaBlock}`
|
||||
: text.trim()
|
||||
: mediaBlock;
|
||||
|
||||
if (params.sendReply) {
|
||||
await params.sendReply(params.target, combined, params.payload.replyToId);
|
||||
} else {
|
||||
@@ -86,6 +93,7 @@ export async function handleIrcInbound(params: {
|
||||
const senderDisplay = message.senderHost
|
||||
? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}`
|
||||
: message.senderNick;
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
|
||||
@@ -129,8 +137,11 @@ export async function handleIrcInbound(params: {
|
||||
const groupAllowFrom =
|
||||
directGroupAllowFrom.length > 0 ? directGroupAllowFrom : wildcardGroupAllowFrom;
|
||||
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean);
|
||||
const effectiveGroupAllowFrom = [...configGroupAllowFrom, ...storeAllowList].filter(Boolean);
|
||||
const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveIrcEffectiveAllowlists({
|
||||
configAllowFrom,
|
||||
configGroupAllowFrom,
|
||||
storeAllowList,
|
||||
});
|
||||
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg: config as OpenClawConfig,
|
||||
@@ -140,6 +151,7 @@ export async function handleIrcInbound(params: {
|
||||
const senderAllowedForCommands = resolveIrcAllowlistMatch({
|
||||
allowFrom: message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom,
|
||||
message,
|
||||
allowNameMatching,
|
||||
}).allowed;
|
||||
const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
|
||||
const commandGate = resolveControlCommandGate({
|
||||
@@ -161,6 +173,7 @@ export async function handleIrcInbound(params: {
|
||||
message,
|
||||
outerAllowFrom: effectiveGroupAllowFrom,
|
||||
innerAllowFrom: groupAllowFrom,
|
||||
allowNameMatching,
|
||||
});
|
||||
if (!senderAllowed) {
|
||||
runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`);
|
||||
@@ -175,6 +188,7 @@ export async function handleIrcInbound(params: {
|
||||
const dmAllowed = resolveIrcAllowlistMatch({
|
||||
allowFrom: effectiveAllowFrom,
|
||||
message,
|
||||
allowNameMatching,
|
||||
}).allowed;
|
||||
if (!dmAllowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
@@ -317,26 +331,22 @@ export async function handleIrcInbound(params: {
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const deliverReply = createNormalizedOutboundDeliverer(async (payload) => {
|
||||
await deliverIrcReply({
|
||||
payload,
|
||||
target: peerId,
|
||||
accountId: account.accountId,
|
||||
sendReply: params.sendReply,
|
||||
statusSink,
|
||||
});
|
||||
});
|
||||
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config as OpenClawConfig,
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
deliver: async (payload) => {
|
||||
await deliverIrcReply({
|
||||
payload: payload as {
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
mediaUrl?: string;
|
||||
replyToId?: string;
|
||||
},
|
||||
target: peerId,
|
||||
accountId: account.accountId,
|
||||
sendReply: params.sendReply,
|
||||
statusSink,
|
||||
});
|
||||
},
|
||||
deliver: deliverReply,
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
@@ -351,3 +361,7 @@ export async function handleIrcInbound(params: {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resolveIrcEffectiveAllowlists,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { createLoggerBackedRuntime, type RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { resolveIrcAccount } from "./accounts.js";
|
||||
import { connectIrcClient, type IrcClient } from "./client.js";
|
||||
import { buildIrcConnectOptions } from "./connect-options.js";
|
||||
@@ -39,13 +39,12 @@ export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ sto
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
|
||||
const runtime: RuntimeEnv = opts.runtime ?? {
|
||||
log: (...args: unknown[]) => core.logging.getChildLogger().info(args.map(String).join(" ")),
|
||||
error: (...args: unknown[]) => core.logging.getChildLogger().error(args.map(String).join(" ")),
|
||||
exit: () => {
|
||||
throw new Error("Runtime exit not available");
|
||||
},
|
||||
};
|
||||
const runtime: RuntimeEnv =
|
||||
opts.runtime ??
|
||||
createLoggerBackedRuntime({
|
||||
logger: core.logging.getChildLogger(),
|
||||
exitError: () => new Error("Runtime exit not available"),
|
||||
});
|
||||
|
||||
if (!account.configured) {
|
||||
throw new Error(
|
||||
|
||||
@@ -30,6 +30,8 @@ describe("irc normalize", () => {
|
||||
};
|
||||
|
||||
expect(buildIrcAllowlistCandidates(message)).toContain("alice!ident@example.org");
|
||||
expect(buildIrcAllowlistCandidates(message)).not.toContain("alice");
|
||||
expect(buildIrcAllowlistCandidates(message, { allowNameMatching: true })).toContain("alice");
|
||||
expect(
|
||||
resolveIrcAllowlistMatch({
|
||||
allowFrom: ["alice!ident@example.org"],
|
||||
@@ -38,9 +40,16 @@ describe("irc normalize", () => {
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveIrcAllowlistMatch({
|
||||
allowFrom: ["bob"],
|
||||
allowFrom: ["alice"],
|
||||
message,
|
||||
}).allowed,
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveIrcAllowlistMatch({
|
||||
allowFrom: ["alice"],
|
||||
message,
|
||||
allowNameMatching: true,
|
||||
}).allowed,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,12 +77,15 @@ export function formatIrcSenderId(message: IrcInboundMessage): string {
|
||||
return base;
|
||||
}
|
||||
|
||||
export function buildIrcAllowlistCandidates(message: IrcInboundMessage): string[] {
|
||||
export function buildIrcAllowlistCandidates(
|
||||
message: IrcInboundMessage,
|
||||
params?: { allowNameMatching?: boolean },
|
||||
): string[] {
|
||||
const nick = message.senderNick.trim().toLowerCase();
|
||||
const user = message.senderUser?.trim().toLowerCase();
|
||||
const host = message.senderHost?.trim().toLowerCase();
|
||||
const candidates = new Set<string>();
|
||||
if (nick) {
|
||||
if (nick && params?.allowNameMatching === true) {
|
||||
candidates.add(nick);
|
||||
}
|
||||
if (nick && user) {
|
||||
@@ -100,6 +103,7 @@ export function buildIrcAllowlistCandidates(message: IrcInboundMessage): string[
|
||||
export function resolveIrcAllowlistMatch(params: {
|
||||
allowFrom: string[];
|
||||
message: IrcInboundMessage;
|
||||
allowNameMatching?: boolean;
|
||||
}): { allowed: boolean; source?: string } {
|
||||
const allowFrom = new Set(
|
||||
params.allowFrom.map((entry) => entry.trim().toLowerCase()).filter(Boolean),
|
||||
@@ -107,7 +111,9 @@ export function resolveIrcAllowlistMatch(params: {
|
||||
if (allowFrom.has("*")) {
|
||||
return { allowed: true, source: "wildcard" };
|
||||
}
|
||||
const candidates = buildIrcAllowlistCandidates(params.message);
|
||||
const candidates = buildIrcAllowlistCandidates(params.message, {
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
});
|
||||
for (const candidate of candidates) {
|
||||
if (allowFrom.has(candidate)) {
|
||||
return { allowed: true, source: candidate };
|
||||
|
||||
@@ -50,6 +50,14 @@ describe("irc policy", () => {
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
resolveIrcGroupSenderAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
message,
|
||||
outerAllowFrom: ["alice!ident@example.org"],
|
||||
innerAllowFrom: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveIrcGroupSenderAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
@@ -57,6 +65,15 @@ describe("irc policy", () => {
|
||||
outerAllowFrom: ["alice"],
|
||||
innerAllowFrom: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveIrcGroupSenderAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
message,
|
||||
outerAllowFrom: ["alice"],
|
||||
innerAllowFrom: [],
|
||||
allowNameMatching: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -142,16 +142,25 @@ export function resolveIrcGroupSenderAllowed(params: {
|
||||
message: IrcInboundMessage;
|
||||
outerAllowFrom: string[];
|
||||
innerAllowFrom: string[];
|
||||
allowNameMatching?: boolean;
|
||||
}): boolean {
|
||||
const policy = params.groupPolicy ?? "allowlist";
|
||||
const inner = normalizeIrcAllowlist(params.innerAllowFrom);
|
||||
const outer = normalizeIrcAllowlist(params.outerAllowFrom);
|
||||
|
||||
if (inner.length > 0) {
|
||||
return resolveIrcAllowlistMatch({ allowFrom: inner, message: params.message }).allowed;
|
||||
return resolveIrcAllowlistMatch({
|
||||
allowFrom: inner,
|
||||
message: params.message,
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
}).allowed;
|
||||
}
|
||||
if (outer.length > 0) {
|
||||
return resolveIrcAllowlistMatch({ allowFrom: outer, message: params.message }).allowed;
|
||||
return resolveIrcAllowlistMatch({
|
||||
allowFrom: outer,
|
||||
message: params.message,
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
}).allowed;
|
||||
}
|
||||
return policy === "open";
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ export type IrcNickServConfig = {
|
||||
export type IrcAccountConfig = {
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Break-glass override: allow nick-only allowlist matching.
|
||||
* Default behavior requires host/user-qualified identities.
|
||||
*/
|
||||
dangerouslyAllowNameMatching?: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
tls?: boolean;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
{
|
||||
"name": "@openclaw/line",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.25",
|
||||
"private": true,
|
||||
"description": "OpenClaw LINE channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type {
|
||||
OpenClawConfig,
|
||||
PluginRuntime,
|
||||
ResolvedLineAccount,
|
||||
RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
|
||||
import { linePlugin } from "./channel.js";
|
||||
import { setLineRuntime } from "./runtime.js";
|
||||
|
||||
@@ -47,16 +43,6 @@ function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
|
||||
return { runtime, mocks: { writeConfigFile, resolveLineAccount } };
|
||||
}
|
||||
|
||||
function createRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAccount(
|
||||
resolveLineAccount: LineRuntimeMocks["resolveLineAccount"],
|
||||
cfg: OpenClawConfig,
|
||||
|
||||
@@ -4,9 +4,9 @@ import type {
|
||||
OpenClawConfig,
|
||||
PluginRuntime,
|
||||
ResolvedLineAccount,
|
||||
RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
|
||||
import { linePlugin } from "./channel.js";
|
||||
import { setLineRuntime } from "./runtime.js";
|
||||
|
||||
@@ -33,20 +33,10 @@ function createRuntime() {
|
||||
return { runtime, probeLineBot, monitorLineProvider };
|
||||
}
|
||||
|
||||
function createRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createStartAccountCtx(params: {
|
||||
token: string;
|
||||
secret: string;
|
||||
runtime: RuntimeEnv;
|
||||
runtime: ReturnType<typeof createRuntimeEnv>;
|
||||
}): ChannelGatewayContext<ResolvedLineAccount> {
|
||||
const snapshot: ChannelAccountSnapshot = {
|
||||
accountId: "default",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
buildTokenChannelStatusSummary,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
LineConfigSchema,
|
||||
processLineMessage,
|
||||
@@ -595,17 +596,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
}
|
||||
return issues;
|
||||
},
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
tokenSource: snapshot.tokenSource ?? "none",
|
||||
running: snapshot.running ?? false,
|
||||
mode: snapshot.mode ?? null,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
getLineRuntime().channel.line.probeLineBot(account.channelAccessToken, timeoutMs),
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/llm-task",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.25",
|
||||
"private": true,
|
||||
"description": "OpenClaw JSON-only LLM task plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import Ajv from "ajv";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk";
|
||||
// NOTE: This extension is intended to be bundled with OpenClaw.
|
||||
// When running from source (tests/dev), OpenClaw internals live under src/.
|
||||
// When running from a built install, internals live under dist/ (no src/ tree).
|
||||
@@ -96,7 +96,11 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
|
||||
|
||||
const pluginCfg = (api.pluginConfig ?? {}) as PluginCfg;
|
||||
|
||||
const primary = api.config?.agents?.defaults?.model?.primary;
|
||||
const defaultsModel = api.config?.agents?.defaults?.model;
|
||||
const primary =
|
||||
typeof defaultsModel === "string"
|
||||
? defaultsModel.trim()
|
||||
: (defaultsModel?.primary?.trim() ?? undefined);
|
||||
const primaryProvider = typeof primary === "string" ? primary.split("/")[0] : undefined;
|
||||
const primaryModel =
|
||||
typeof primary === "string" ? primary.split("/").slice(1).join("/") : undefined;
|
||||
@@ -176,7 +180,9 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
|
||||
|
||||
let tmpDir: string | null = null;
|
||||
try {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-llm-task-"));
|
||||
tmpDir = await fs.mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-llm-task-"),
|
||||
);
|
||||
const sessionId = `llm-task-${Date.now()}`;
|
||||
const sessionFile = path.join(tmpDir, "session.json");
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/lobster",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.25",
|
||||
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
|
||||
@@ -5,6 +5,12 @@ import path from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../../../src/plugins/types.js";
|
||||
import {
|
||||
createWindowsCmdShimFixture,
|
||||
restorePlatformPathEnv,
|
||||
setProcessPlatform,
|
||||
snapshotPlatformPathEnv,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
const spawnState = vi.hoisted(() => ({
|
||||
queue: [] as Array<{ stdout: string; stderr?: string; exitCode?: number }>,
|
||||
@@ -57,20 +63,9 @@ function fakeCtx(overrides: Partial<OpenClawPluginToolContext> = {}): OpenClawPl
|
||||
};
|
||||
}
|
||||
|
||||
function setProcessPlatform(platform: NodeJS.Platform) {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: platform,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe("lobster plugin tool", () => {
|
||||
let tempDir = "";
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
const originalPath = process.env.PATH;
|
||||
const originalPathAlt = process.env.Path;
|
||||
const originalPathExt = process.env.PATHEXT;
|
||||
const originalPathExtAlt = process.env.Pathext;
|
||||
const originalProcessState = snapshotPlatformPathEnv();
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createLobsterTool } = await import("./lobster-tool.js"));
|
||||
@@ -79,29 +74,7 @@ describe("lobster plugin tool", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, "platform", originalPlatform);
|
||||
}
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
if (originalPathAlt === undefined) {
|
||||
delete process.env.Path;
|
||||
} else {
|
||||
process.env.Path = originalPathAlt;
|
||||
}
|
||||
if (originalPathExt === undefined) {
|
||||
delete process.env.PATHEXT;
|
||||
} else {
|
||||
process.env.PATHEXT = originalPathExt;
|
||||
}
|
||||
if (originalPathExtAlt === undefined) {
|
||||
delete process.env.Pathext;
|
||||
} else {
|
||||
process.env.Pathext = originalPathExtAlt;
|
||||
}
|
||||
restorePlatformPathEnv(originalProcessState);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -156,17 +129,6 @@ describe("lobster plugin tool", () => {
|
||||
});
|
||||
};
|
||||
|
||||
const createWindowsShimFixture = async (params: {
|
||||
shimPath: string;
|
||||
scriptPath: string;
|
||||
scriptToken: string;
|
||||
}) => {
|
||||
await fs.mkdir(path.dirname(params.scriptPath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(params.shimPath), { recursive: true });
|
||||
await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8");
|
||||
await fs.writeFile(params.shimPath, `@echo off\r\n"${params.scriptToken}" %*\r\n`, "utf8");
|
||||
};
|
||||
|
||||
it("runs lobster and returns parsed envelope in details", async () => {
|
||||
spawnState.queue.push({
|
||||
stdout: JSON.stringify({
|
||||
@@ -281,10 +243,10 @@ describe("lobster plugin tool", () => {
|
||||
setProcessPlatform("win32");
|
||||
const shimScriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
|
||||
const shimPath = path.join(tempDir, "shim-bin", "lobster.cmd");
|
||||
await createWindowsShimFixture({
|
||||
await createWindowsCmdShimFixture({
|
||||
shimPath,
|
||||
scriptPath: shimScriptPath,
|
||||
scriptToken: "%dp0%\\..\\shim-dist\\lobster-cli.cjs",
|
||||
shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
|
||||
});
|
||||
process.env.PATHEXT = ".CMD;.EXE";
|
||||
process.env.PATH = `${path.dirname(shimPath)};${process.env.PATH ?? ""}`;
|
||||
|
||||
56
extensions/lobster/src/test-helpers.ts
Normal file
56
extensions/lobster/src/test-helpers.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
type PathEnvKey = "PATH" | "Path" | "PATHEXT" | "Pathext";
|
||||
|
||||
const PATH_ENV_KEYS = ["PATH", "Path", "PATHEXT", "Pathext"] as const;
|
||||
|
||||
export type PlatformPathEnvSnapshot = {
|
||||
platformDescriptor: PropertyDescriptor | undefined;
|
||||
env: Record<PathEnvKey, string | undefined>;
|
||||
};
|
||||
|
||||
export function setProcessPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: platform,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function snapshotPlatformPathEnv(): PlatformPathEnvSnapshot {
|
||||
return {
|
||||
platformDescriptor: Object.getOwnPropertyDescriptor(process, "platform"),
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
Path: process.env.Path,
|
||||
PATHEXT: process.env.PATHEXT,
|
||||
Pathext: process.env.Pathext,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function restorePlatformPathEnv(snapshot: PlatformPathEnvSnapshot): void {
|
||||
if (snapshot.platformDescriptor) {
|
||||
Object.defineProperty(process, "platform", snapshot.platformDescriptor);
|
||||
}
|
||||
|
||||
for (const key of PATH_ENV_KEYS) {
|
||||
const value = snapshot.env[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
continue;
|
||||
}
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWindowsCmdShimFixture(params: {
|
||||
shimPath: string;
|
||||
scriptPath: string;
|
||||
shimLine: string;
|
||||
}): Promise<void> {
|
||||
await fs.mkdir(path.dirname(params.scriptPath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(params.shimPath), { recursive: true });
|
||||
await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8");
|
||||
await fs.writeFile(params.shimPath, `@echo off\r\n${params.shimLine}\r\n`, "utf8");
|
||||
}
|
||||
@@ -2,22 +2,17 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createWindowsCmdShimFixture,
|
||||
restorePlatformPathEnv,
|
||||
setProcessPlatform,
|
||||
snapshotPlatformPathEnv,
|
||||
} from "./test-helpers.js";
|
||||
import { resolveWindowsLobsterSpawn } from "./windows-spawn.js";
|
||||
|
||||
function setProcessPlatform(platform: NodeJS.Platform) {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: platform,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveWindowsLobsterSpawn", () => {
|
||||
let tempDir = "";
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
const originalPath = process.env.PATH;
|
||||
const originalPathAlt = process.env.Path;
|
||||
const originalPathExt = process.env.PATHEXT;
|
||||
const originalPathExtAlt = process.env.Pathext;
|
||||
const originalProcessState = snapshotPlatformPathEnv();
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-win-spawn-"));
|
||||
@@ -25,29 +20,7 @@ describe("resolveWindowsLobsterSpawn", () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, "platform", originalPlatform);
|
||||
}
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
if (originalPathAlt === undefined) {
|
||||
delete process.env.Path;
|
||||
} else {
|
||||
process.env.Path = originalPathAlt;
|
||||
}
|
||||
if (originalPathExt === undefined) {
|
||||
delete process.env.PATHEXT;
|
||||
} else {
|
||||
process.env.PATHEXT = originalPathExt;
|
||||
}
|
||||
if (originalPathExtAlt === undefined) {
|
||||
delete process.env.Pathext;
|
||||
} else {
|
||||
process.env.Pathext = originalPathExtAlt;
|
||||
}
|
||||
restorePlatformPathEnv(originalProcessState);
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = "";
|
||||
@@ -57,14 +30,11 @@ describe("resolveWindowsLobsterSpawn", () => {
|
||||
it("unwraps cmd shim with %dp0% token", async () => {
|
||||
const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
|
||||
const shimPath = path.join(tempDir, "shim", "lobster.cmd");
|
||||
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(shimPath), { recursive: true });
|
||||
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
|
||||
await fs.writeFile(
|
||||
await createWindowsCmdShimFixture({
|
||||
shimPath,
|
||||
`@echo off\r\n"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`,
|
||||
"utf8",
|
||||
);
|
||||
scriptPath,
|
||||
shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
|
||||
});
|
||||
|
||||
const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env);
|
||||
expect(target.command).toBe(process.execPath);
|
||||
@@ -75,14 +45,11 @@ describe("resolveWindowsLobsterSpawn", () => {
|
||||
it("unwraps cmd shim with %~dp0% token", async () => {
|
||||
const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
|
||||
const shimPath = path.join(tempDir, "shim", "lobster.cmd");
|
||||
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(shimPath), { recursive: true });
|
||||
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
|
||||
await fs.writeFile(
|
||||
await createWindowsCmdShimFixture({
|
||||
shimPath,
|
||||
`@echo off\r\n"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`,
|
||||
"utf8",
|
||||
);
|
||||
scriptPath,
|
||||
shimLine: `"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
|
||||
});
|
||||
|
||||
const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env);
|
||||
expect(target.command).toBe(process.execPath);
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.25
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.24
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.22
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
{
|
||||
"name": "@openclaw/matrix",
|
||||
"version": "2026.2.6-3",
|
||||
"version": "2026.2.25",
|
||||
"description": "OpenClaw Matrix channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"markdown-it": "14.1.0",
|
||||
"matrix-js-sdk": "^40.1.0",
|
||||
"music-metadata": "^11.11.2",
|
||||
"@vector-im/matrix-bot-sdk": "0.8.0-element.3",
|
||||
"markdown-it": "14.1.1",
|
||||
"music-metadata": "^11.12.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
@@ -39,9 +39,6 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||
if (gate("channelInfo")) {
|
||||
actions.add("channel-info");
|
||||
}
|
||||
if (account.config.encryption === true && gate("verification")) {
|
||||
actions.add("permissions");
|
||||
}
|
||||
return Array.from(actions);
|
||||
},
|
||||
supportsAction: ({ action }) => action !== "poll",
|
||||
@@ -193,45 +190,6 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "permissions") {
|
||||
const operation = (
|
||||
readStringParam(params, "operation") ??
|
||||
readStringParam(params, "mode") ??
|
||||
"verification-list"
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const operationToAction: Record<string, string> = {
|
||||
"encryption-status": "encryptionStatus",
|
||||
"verification-list": "verificationList",
|
||||
"verification-request": "verificationRequest",
|
||||
"verification-accept": "verificationAccept",
|
||||
"verification-cancel": "verificationCancel",
|
||||
"verification-start": "verificationStart",
|
||||
"verification-generate-qr": "verificationGenerateQr",
|
||||
"verification-scan-qr": "verificationScanQr",
|
||||
"verification-sas": "verificationSas",
|
||||
"verification-confirm": "verificationConfirm",
|
||||
"verification-mismatch": "verificationMismatch",
|
||||
"verification-confirm-qr": "verificationConfirmQr",
|
||||
};
|
||||
const resolvedAction = operationToAction[operation];
|
||||
if (!resolvedAction) {
|
||||
throw new Error(
|
||||
`Unsupported Matrix permissions operation: ${operation}. Supported values: ${Object.keys(
|
||||
operationToAction,
|
||||
).join(", ")}`,
|
||||
);
|
||||
}
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
...params,
|
||||
action: resolvedAction,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Action ${action} is not supported for provider matrix.`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -68,7 +68,6 @@ function buildMatrixConfigUpdate(
|
||||
userId?: string;
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
register?: boolean;
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: number;
|
||||
},
|
||||
@@ -85,7 +84,6 @@ function buildMatrixConfigUpdate(
|
||||
...(input.userId ? { userId: input.userId } : {}),
|
||||
...(input.accessToken ? { accessToken: input.accessToken } : {}),
|
||||
...(input.password ? { password: input.password } : {}),
|
||||
...(typeof input.register === "boolean" ? { register: input.register } : {}),
|
||||
...(input.deviceName ? { deviceName: input.deviceName } : {}),
|
||||
...(typeof input.initialSyncLimit === "number"
|
||||
? { initialSyncLimit: input.initialSyncLimit }
|
||||
@@ -138,7 +136,6 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
"userId",
|
||||
"accessToken",
|
||||
"password",
|
||||
"register",
|
||||
"deviceName",
|
||||
"initialSyncLimit",
|
||||
],
|
||||
|
||||
@@ -10,7 +10,6 @@ const matrixActionSchema = z
|
||||
pins: z.boolean().optional(),
|
||||
memberInfo: z.boolean().optional(),
|
||||
channelInfo: z.boolean().optional(),
|
||||
verification: z.boolean().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
@@ -43,8 +42,6 @@ export const MatrixConfigSchema = z.object({
|
||||
userId: z.string().optional(),
|
||||
accessToken: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
register: z.boolean().optional(),
|
||||
deviceId: z.string().optional(),
|
||||
deviceName: z.string().optional(),
|
||||
initialSyncLimit: z.number().optional(),
|
||||
encryption: z.boolean().optional(),
|
||||
|
||||
@@ -12,18 +12,4 @@ export {
|
||||
export { listMatrixReactions, removeMatrixReactions } from "./actions/reactions.js";
|
||||
export { pinMatrixMessage, unpinMatrixMessage, listMatrixPins } from "./actions/pins.js";
|
||||
export { getMatrixMemberInfo, getMatrixRoomInfo } from "./actions/room.js";
|
||||
export {
|
||||
acceptMatrixVerification,
|
||||
cancelMatrixVerification,
|
||||
confirmMatrixVerificationReciprocateQr,
|
||||
confirmMatrixVerificationSas,
|
||||
generateMatrixVerificationQr,
|
||||
getMatrixEncryptionStatus,
|
||||
getMatrixVerificationSas,
|
||||
listMatrixVerifications,
|
||||
mismatchMatrixVerificationSas,
|
||||
requestMatrixVerification,
|
||||
scanMatrixVerificationQr,
|
||||
startMatrixVerification,
|
||||
} from "./actions/verification.js";
|
||||
export { reactMatrixMessage } from "./send.js";
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { getActiveMatrixClient } from "../active-client.js";
|
||||
import {
|
||||
createMatrixClient,
|
||||
isBunRuntime,
|
||||
resolveMatrixAuth,
|
||||
resolveSharedMatrixClient,
|
||||
} from "../client.js";
|
||||
import { createPreparedMatrixClient } from "../client-bootstrap.js";
|
||||
import { isBunRuntime, resolveMatrixAuth, resolveSharedMatrixClient } from "../client.js";
|
||||
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
export function ensureNodeRuntime() {
|
||||
if (isBunRuntime()) {
|
||||
@@ -22,7 +19,9 @@ export async function resolveActionClient(
|
||||
if (opts.client) {
|
||||
return { client: opts.client, stopOnDone: false };
|
||||
}
|
||||
const active = getActiveMatrixClient();
|
||||
// Normalize accountId early to ensure consistent keying across all lookups
|
||||
const accountId = normalizeAccountId(opts.accountId);
|
||||
const active = getActiveMatrixClient(accountId);
|
||||
if (active) {
|
||||
return { client: active, stopOnDone: false };
|
||||
}
|
||||
@@ -31,29 +30,18 @@ export async function resolveActionClient(
|
||||
const client = await resolveSharedMatrixClient({
|
||||
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId,
|
||||
});
|
||||
return { client, stopOnDone: false };
|
||||
}
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
accountId,
|
||||
});
|
||||
const client = await createMatrixClient({
|
||||
homeserver: auth.homeserver,
|
||||
userId: auth.userId,
|
||||
accessToken: auth.accessToken,
|
||||
password: auth.password,
|
||||
deviceId: auth.deviceId,
|
||||
encryption: auth.encryption,
|
||||
localTimeoutMs: opts.timeoutMs,
|
||||
const client = await createPreparedMatrixClient({
|
||||
auth,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId,
|
||||
});
|
||||
if (auth.encryption && client.crypto) {
|
||||
try {
|
||||
const joinedRooms = await client.getJoinedRooms();
|
||||
await client.crypto.prepare(joinedRooms);
|
||||
} catch {
|
||||
// Ignore crypto prep failures for one-off actions.
|
||||
}
|
||||
}
|
||||
await client.start();
|
||||
return { client, stopOnDone: true };
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ export async function readMatrixMessages(
|
||||
const limit = resolveMatrixActionLimit(opts.limit, 20);
|
||||
const token = opts.before?.trim() || opts.after?.trim() || undefined;
|
||||
const dir = opts.after ? "f" : "b";
|
||||
// Room history is queried via the low-level endpoint for compatibility.
|
||||
// @vector-im/matrix-bot-sdk uses doRequest for room messages
|
||||
const res = (await client.doRequest(
|
||||
"GET",
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { resolveMatrixRoomId } from "../send.js";
|
||||
import { resolveActionClient } from "./client.js";
|
||||
import { resolveMatrixActionLimit } from "./limits.js";
|
||||
import {
|
||||
EventType,
|
||||
RelationType,
|
||||
@@ -9,6 +10,23 @@ import {
|
||||
type ReactionEventContent,
|
||||
} from "./types.js";
|
||||
|
||||
function getReactionsPath(roomId: string, messageId: string): string {
|
||||
return `/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`;
|
||||
}
|
||||
|
||||
async function listReactionEvents(
|
||||
client: NonNullable<MatrixActionClientOpts["client"]>,
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
limit: number,
|
||||
): Promise<MatrixRawEvent[]> {
|
||||
const res = (await client.doRequest("GET", getReactionsPath(roomId, messageId), {
|
||||
dir: "b",
|
||||
limit,
|
||||
})) as { chunk: MatrixRawEvent[] };
|
||||
return res.chunk;
|
||||
}
|
||||
|
||||
export async function listMatrixReactions(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
@@ -17,18 +35,10 @@ export async function listMatrixReactions(
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const limit =
|
||||
typeof opts.limit === "number" && Number.isFinite(opts.limit)
|
||||
? Math.max(1, Math.floor(opts.limit))
|
||||
: 100;
|
||||
// Relations are queried via the low-level endpoint for compatibility.
|
||||
const res = (await client.doRequest(
|
||||
"GET",
|
||||
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
|
||||
{ dir: "b", limit },
|
||||
)) as { chunk: MatrixRawEvent[] };
|
||||
const limit = resolveMatrixActionLimit(opts.limit, 100);
|
||||
const chunk = await listReactionEvents(client, resolvedRoom, messageId, limit);
|
||||
const summaries = new Map<string, MatrixReactionSummary>();
|
||||
for (const event of res.chunk) {
|
||||
for (const event of chunk) {
|
||||
const content = event.content as ReactionEventContent;
|
||||
const key = content["m.relates_to"]?.key;
|
||||
if (!key) {
|
||||
@@ -62,17 +72,13 @@ export async function removeMatrixReactions(
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const res = (await client.doRequest(
|
||||
"GET",
|
||||
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
|
||||
{ dir: "b", limit: 200 },
|
||||
)) as { chunk: MatrixRawEvent[] };
|
||||
const chunk = await listReactionEvents(client, resolvedRoom, messageId, 200);
|
||||
const userId = await client.getUserId();
|
||||
if (!userId) {
|
||||
return { removed: 0 };
|
||||
}
|
||||
const targetEmoji = opts.emoji?.trim();
|
||||
const toRemove = res.chunk
|
||||
const toRemove = chunk
|
||||
.filter((event) => event.sender === userId)
|
||||
.filter((event) => {
|
||||
if (!targetEmoji) {
|
||||
|
||||
@@ -9,8 +9,10 @@ export async function getMatrixMemberInfo(
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
|
||||
// @vector-im/matrix-bot-sdk uses getUserProfile
|
||||
const profile = await client.getUserProfile(userId);
|
||||
// Membership and power levels are not included in profile calls; fetch state separately if needed.
|
||||
// Note: @vector-im/matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk
|
||||
// We'd need to fetch room state separately if needed
|
||||
return {
|
||||
userId,
|
||||
profile: {
|
||||
@@ -33,6 +35,7 @@ export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClient
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
// @vector-im/matrix-bot-sdk uses getRoomState for state events
|
||||
let name: string | null = null;
|
||||
let topic: string | null = null;
|
||||
let canonicalAlias: string | null = null;
|
||||
@@ -40,21 +43,21 @@ export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClient
|
||||
|
||||
try {
|
||||
const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", "");
|
||||
name = typeof nameState?.name === "string" ? nameState.name : null;
|
||||
name = nameState?.name ?? null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, "");
|
||||
topic = typeof topicState?.topic === "string" ? topicState.topic : null;
|
||||
topic = topicState?.topic ?? null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const aliasState = await client.getRoomStateEvent(resolvedRoom, "m.room.canonical_alias", "");
|
||||
canonicalAlias = typeof aliasState?.alias === "string" ? aliasState.alias : null;
|
||||
canonicalAlias = aliasState?.alias ?? null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import {
|
||||
EventType,
|
||||
type MatrixMessageSummary,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MatrixClient, MatrixRawEvent, MessageEventContent } from "../sdk.js";
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
|
||||
export const MsgType = {
|
||||
Text: "m.text",
|
||||
@@ -16,7 +16,7 @@ export const EventType = {
|
||||
Reaction: "m.reaction",
|
||||
} as const;
|
||||
|
||||
export type RoomMessageEventContent = MessageEventContent & {
|
||||
export type RoomMessageEventContent = {
|
||||
msgtype: string;
|
||||
body: string;
|
||||
"m.new_content"?: RoomMessageEventContent;
|
||||
@@ -43,6 +43,17 @@ export type RoomTopicEventContent = {
|
||||
topic?: string;
|
||||
};
|
||||
|
||||
export type MatrixRawEvent = {
|
||||
event_id: string;
|
||||
sender: string;
|
||||
type: string;
|
||||
origin_server_ts: number;
|
||||
content: Record<string, unknown>;
|
||||
unsigned?: {
|
||||
redacted_because?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type MatrixActionClientOpts = {
|
||||
client?: MatrixClient;
|
||||
timeoutMs?: number;
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
import type { MatrixActionClientOpts } from "./types.js";
|
||||
import { resolveActionClient } from "./client.js";
|
||||
|
||||
function requireCrypto(
|
||||
client: import("../sdk.js").MatrixClient,
|
||||
): NonNullable<import("../sdk.js").MatrixClient["crypto"]> {
|
||||
if (!client.crypto) {
|
||||
throw new Error("Matrix encryption is not available (enable channels.matrix.encryption=true)");
|
||||
}
|
||||
return client.crypto;
|
||||
}
|
||||
|
||||
function resolveVerificationId(input: string): string {
|
||||
const normalized = input.trim();
|
||||
if (!normalized) {
|
||||
throw new Error("Matrix verification request id is required");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function listMatrixVerifications(opts: MatrixActionClientOpts = {}) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const crypto = requireCrypto(client);
|
||||
return await crypto.listVerifications();
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestMatrixVerification(
|
||||
params: MatrixActionClientOpts & {
|
||||
ownUser?: boolean;
|
||||
userId?: string;
|
||||
deviceId?: string;
|
||||
roomId?: string;
|
||||
} = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(params);
|
||||
try {
|
||||
const crypto = requireCrypto(client);
|
||||
const ownUser = params.ownUser ?? (!params.userId && !params.deviceId && !params.roomId);
|
||||
return await crypto.requestVerification({
|
||||
ownUser,
|
||||
userId: params.userId?.trim() || undefined,
|
||||
deviceId: params.deviceId?.trim() || undefined,
|
||||
roomId: params.roomId?.trim() || undefined,
|
||||
});
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function acceptMatrixVerification(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const crypto = requireCrypto(client);
|
||||
return await crypto.acceptVerification(resolveVerificationId(requestId));
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelMatrixVerification(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts & { reason?: string; code?: string } = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const crypto = requireCrypto(client);
|
||||
return await crypto.cancelVerification(resolveVerificationId(requestId), {
|
||||
reason: opts.reason?.trim() || undefined,
|
||||
code: opts.code?.trim() || undefined,
|
||||
});
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function startMatrixVerification(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts & { method?: "sas" } = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const crypto = requireCrypto(client);
|
||||
return await crypto.startVerification(resolveVerificationId(requestId), opts.method ?? "sas");
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMatrixVerificationQr(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const crypto = requireCrypto(client);
|
||||
return await crypto.generateVerificationQr(resolveVerificationId(requestId));
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function scanMatrixVerificationQr(
|
||||
requestId: string,
|
||||
qrDataBase64: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const crypto = requireCrypto(client);
|
||||
const payload = qrDataBase64.trim();
|
||||
if (!payload) {
|
||||
throw new Error("Matrix QR data is required");
|
||||
}
|
||||
return await crypto.scanVerificationQr(resolveVerificationId(requestId), payload);
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMatrixVerificationSas(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const crypto = requireCrypto(client);
|
||||
return await crypto.getVerificationSas(resolveVerificationId(requestId));
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function confirmMatrixVerificationSas(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const crypto = requireCrypto(client);
|
||||
return await crypto.confirmVerificationSas(resolveVerificationId(requestId));
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function mismatchMatrixVerificationSas(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const crypto = requireCrypto(client);
|
||||
return await crypto.mismatchVerificationSas(resolveVerificationId(requestId));
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function confirmMatrixVerificationReciprocateQr(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const crypto = requireCrypto(client);
|
||||
return await crypto.confirmVerificationReciprocateQr(resolveVerificationId(requestId));
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMatrixEncryptionStatus(
|
||||
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const crypto = requireCrypto(client);
|
||||
const recoveryKey = await crypto.getRecoveryKey();
|
||||
return {
|
||||
encryptionEnabled: true,
|
||||
recoveryKeyStored: Boolean(recoveryKey),
|
||||
recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null,
|
||||
...(opts.includeRecoveryKey ? { recoveryKey: recoveryKey?.encodedPrivateKey ?? null } : {}),
|
||||
pendingVerifications: (await crypto.listVerifications()).length,
|
||||
};
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,32 @@
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
|
||||
let activeClient: MatrixClient | null = null;
|
||||
// Support multiple active clients for multi-account
|
||||
const activeClients = new Map<string, MatrixClient>();
|
||||
|
||||
export function setActiveMatrixClient(client: MatrixClient | null): void {
|
||||
activeClient = client;
|
||||
export function setActiveMatrixClient(
|
||||
client: MatrixClient | null,
|
||||
accountId?: string | null,
|
||||
): void {
|
||||
const key = normalizeAccountId(accountId);
|
||||
if (client) {
|
||||
activeClients.set(key, client);
|
||||
} else {
|
||||
activeClients.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveMatrixClient(): MatrixClient | null {
|
||||
return activeClient;
|
||||
export function getActiveMatrixClient(accountId?: string | null): MatrixClient | null {
|
||||
const key = normalizeAccountId(accountId);
|
||||
return activeClients.get(key) ?? null;
|
||||
}
|
||||
|
||||
export function getAnyActiveMatrixClient(): MatrixClient | null {
|
||||
// Return any available client (for backward compatibility)
|
||||
const first = activeClients.values().next();
|
||||
return first.done ? null : first.value;
|
||||
}
|
||||
|
||||
export function clearAllActiveMatrixClients(): void {
|
||||
activeClients.clear();
|
||||
}
|
||||
|
||||
@@ -1,26 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { resolveMatrixAuth, resolveMatrixConfig } from "./client.js";
|
||||
import * as credentialsModule from "./credentials.js";
|
||||
import * as sdkModule from "./sdk.js";
|
||||
|
||||
const saveMatrixCredentialsMock = vi.fn();
|
||||
const prepareMatrixRegisterModeMock = vi.fn(async () => null);
|
||||
const finalizeMatrixRegisterConfigAfterSuccessMock = vi.fn(async () => false);
|
||||
|
||||
vi.mock("./credentials.js", () => ({
|
||||
loadMatrixCredentials: vi.fn(() => null),
|
||||
saveMatrixCredentials: (...args: unknown[]) => saveMatrixCredentialsMock(...args),
|
||||
credentialsMatchConfig: vi.fn(() => false),
|
||||
touchMatrixCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./client/register-mode.js", () => ({
|
||||
prepareMatrixRegisterMode: (...args: unknown[]) => prepareMatrixRegisterModeMock(...args),
|
||||
finalizeMatrixRegisterConfigAfterSuccess: (...args: unknown[]) =>
|
||||
finalizeMatrixRegisterConfigAfterSuccessMock(...args),
|
||||
resetPreparedMatrixRegisterModesForTests: vi.fn(),
|
||||
}));
|
||||
import { resolveMatrixConfig } from "./client.js";
|
||||
|
||||
describe("resolveMatrixConfig", () => {
|
||||
it("prefers config over env", () => {
|
||||
@@ -49,8 +29,6 @@ describe("resolveMatrixConfig", () => {
|
||||
userId: "@cfg:example.org",
|
||||
accessToken: "cfg-token",
|
||||
password: "cfg-pass",
|
||||
register: false,
|
||||
deviceId: undefined,
|
||||
deviceName: "CfgDevice",
|
||||
initialSyncLimit: 5,
|
||||
encryption: false,
|
||||
@@ -64,7 +42,6 @@ describe("resolveMatrixConfig", () => {
|
||||
MATRIX_USER_ID: "@env:example.org",
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
MATRIX_DEVICE_ID: "ENVDEVICE",
|
||||
MATRIX_DEVICE_NAME: "EnvDevice",
|
||||
} as NodeJS.ProcessEnv;
|
||||
const resolved = resolveMatrixConfig(cfg, env);
|
||||
@@ -72,328 +49,8 @@ describe("resolveMatrixConfig", () => {
|
||||
expect(resolved.userId).toBe("@env:example.org");
|
||||
expect(resolved.accessToken).toBe("env-token");
|
||||
expect(resolved.password).toBe("env-pass");
|
||||
expect(resolved.register).toBe(false);
|
||||
expect(resolved.deviceId).toBe("ENVDEVICE");
|
||||
expect(resolved.deviceName).toBe("EnvDevice");
|
||||
expect(resolved.initialSyncLimit).toBeUndefined();
|
||||
expect(resolved.encryption).toBe(false);
|
||||
});
|
||||
|
||||
it("reads register flag from config and env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
register: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const resolvedFromCfg = resolveMatrixConfig(cfg, {} as NodeJS.ProcessEnv);
|
||||
expect(resolvedFromCfg.register).toBe(true);
|
||||
|
||||
const resolvedFromEnv = resolveMatrixConfig(
|
||||
{} as CoreConfig,
|
||||
{
|
||||
MATRIX_REGISTER: "1",
|
||||
} as NodeJS.ProcessEnv,
|
||||
);
|
||||
expect(resolvedFromEnv.register).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMatrixAuth", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
saveMatrixCredentialsMock.mockReset();
|
||||
prepareMatrixRegisterModeMock.mockReset();
|
||||
finalizeMatrixRegisterConfigAfterSuccessMock.mockReset();
|
||||
});
|
||||
|
||||
it("uses the hardened client request path for password login and persists deviceId", async () => {
|
||||
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
|
||||
access_token: "tok-123",
|
||||
user_id: "@bot:example.org",
|
||||
device_id: "DEVICE123",
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(doRequestSpy).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/_matrix/client/v3/login",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
type: "m.login.password",
|
||||
}),
|
||||
);
|
||||
expect(auth).toMatchObject({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
encryption: true,
|
||||
});
|
||||
expect(saveMatrixCredentialsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("can register account when password login fails and register mode is enabled", async () => {
|
||||
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest");
|
||||
doRequestSpy
|
||||
.mockRejectedValueOnce(new Error("Invalid username or password"))
|
||||
.mockResolvedValueOnce({
|
||||
access_token: "tok-registered",
|
||||
user_id: "@newbot:example.org",
|
||||
device_id: "REGDEVICE123",
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@newbot:example.org",
|
||||
password: "secret",
|
||||
register: true,
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(doRequestSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"POST",
|
||||
"/_matrix/client/v3/login",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
type: "m.login.password",
|
||||
device_id: undefined,
|
||||
}),
|
||||
);
|
||||
expect(doRequestSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"POST",
|
||||
"/_matrix/client/v3/register",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
username: "newbot",
|
||||
auth: { type: "m.login.dummy" },
|
||||
}),
|
||||
);
|
||||
expect(auth).toMatchObject({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@newbot:example.org",
|
||||
accessToken: "tok-registered",
|
||||
deviceId: "REGDEVICE123",
|
||||
encryption: true,
|
||||
});
|
||||
expect(prepareMatrixRegisterModeMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@newbot:example.org",
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
expect(finalizeMatrixRegisterConfigAfterSuccessMock).toHaveBeenCalledWith({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@newbot:example.org",
|
||||
deviceId: "REGDEVICE123",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores cached credentials when matrix.register=true", async () => {
|
||||
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "cached-token",
|
||||
deviceId: "CACHEDDEVICE",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
|
||||
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
|
||||
access_token: "tok-123",
|
||||
user_id: "@bot:example.org",
|
||||
device_id: "DEVICE123",
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret",
|
||||
register: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
|
||||
|
||||
expect(doRequestSpy).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/_matrix/client/v3/login",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
type: "m.login.password",
|
||||
}),
|
||||
);
|
||||
expect(auth.accessToken).toBe("tok-123");
|
||||
expect(prepareMatrixRegisterModeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("requires matrix.password when matrix.register=true", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
register: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
await expect(resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow(
|
||||
"Matrix password is required when matrix.register=true",
|
||||
);
|
||||
expect(prepareMatrixRegisterModeMock).not.toHaveBeenCalled();
|
||||
expect(finalizeMatrixRegisterConfigAfterSuccessMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires matrix.userId when matrix.register=true", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
password: "secret",
|
||||
register: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
await expect(resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow(
|
||||
"Matrix userId is required when matrix.register=true",
|
||||
);
|
||||
expect(prepareMatrixRegisterModeMock).not.toHaveBeenCalled();
|
||||
expect(finalizeMatrixRegisterConfigAfterSuccessMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to config deviceId when cached credentials are missing it", async () => {
|
||||
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
|
||||
|
||||
expect(auth.deviceId).toBe("DEVICE123");
|
||||
expect(saveMatrixCredentialsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves missing whoami identity fields for token auth", async () => {
|
||||
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
|
||||
user_id: "@bot:example.org",
|
||||
device_id: "DEVICE123",
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok-123",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(doRequestSpy).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
|
||||
expect(auth).toMatchObject({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
encryption: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses config deviceId with cached credentials when token is loaded from cache", async () => {
|
||||
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
|
||||
|
||||
expect(auth).toMatchObject({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
encryption: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,113 +1,61 @@
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
|
||||
import { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { MatrixClient } from "../sdk.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||
import {
|
||||
finalizeMatrixRegisterConfigAfterSuccess,
|
||||
prepareMatrixRegisterMode,
|
||||
} from "./register-mode.js";
|
||||
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
|
||||
|
||||
function clean(value?: string): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
function parseOptionalBoolean(value: unknown): boolean | undefined {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
/** Shallow-merge known nested config sub-objects so partial overrides inherit base values. */
|
||||
function deepMergeConfig<T extends Record<string, unknown>>(base: T, override: Partial<T>): T {
|
||||
const merged = { ...base, ...override } as Record<string, unknown>;
|
||||
// Merge known nested objects (dm, actions) so partial overrides keep base fields
|
||||
for (const key of ["dm", "actions"] as const) {
|
||||
const b = base[key];
|
||||
const o = override[key];
|
||||
if (typeof b === "object" && b !== null && typeof o === "object" && o !== null) {
|
||||
merged[key] = { ...(b as Record<string, unknown>), ...(o as Record<string, unknown>) };
|
||||
}
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (["1", "true", "yes", "on"].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (["0", "false", "no", "off"].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
return merged as T;
|
||||
}
|
||||
|
||||
function resolveMatrixLocalpart(userId: string): string {
|
||||
const trimmed = userId.trim();
|
||||
const noPrefix = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
||||
const localpart = noPrefix.split(":")[0]?.trim() || "";
|
||||
if (!localpart) {
|
||||
throw new Error(`Invalid Matrix userId for registration: ${userId}`);
|
||||
}
|
||||
return localpart;
|
||||
}
|
||||
|
||||
async function registerMatrixPasswordAccount(params: {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
password: string;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
}): Promise<{
|
||||
access_token?: string;
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
}> {
|
||||
const registerClient = new MatrixClient(params.homeserver, "");
|
||||
const payload = {
|
||||
username: resolveMatrixLocalpart(params.userId),
|
||||
password: params.password,
|
||||
inhibit_login: false,
|
||||
device_id: params.deviceId,
|
||||
initial_device_display_name: params.deviceName ?? "OpenClaw Gateway",
|
||||
};
|
||||
|
||||
let firstError: unknown = null;
|
||||
try {
|
||||
return (await registerClient.doRequest("POST", "/_matrix/client/v3/register", undefined, {
|
||||
...payload,
|
||||
auth: { type: "m.login.dummy" },
|
||||
})) as {
|
||||
access_token?: string;
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
};
|
||||
} catch (err) {
|
||||
firstError = err;
|
||||
}
|
||||
|
||||
try {
|
||||
return (await registerClient.doRequest(
|
||||
"POST",
|
||||
"/_matrix/client/v3/register",
|
||||
undefined,
|
||||
payload,
|
||||
)) as {
|
||||
access_token?: string;
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
};
|
||||
} catch (err) {
|
||||
const firstMessage = firstError instanceof Error ? firstError.message : String(firstError);
|
||||
const secondMessage = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`Matrix registration failed (dummy auth: ${firstMessage}; plain registration: ${secondMessage})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveMatrixConfig(
|
||||
/**
|
||||
* Resolve Matrix config for a specific account, with fallback to top-level config.
|
||||
* This supports both multi-account (channels.matrix.accounts.*) and
|
||||
* single-account (channels.matrix.*) configurations.
|
||||
*/
|
||||
export function resolveMatrixConfigForAccount(
|
||||
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
accountId?: string | null,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): MatrixResolvedConfig {
|
||||
const matrix = cfg.channels?.matrix ?? {};
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const matrixBase = cfg.channels?.matrix ?? {};
|
||||
const accounts = cfg.channels?.matrix?.accounts;
|
||||
|
||||
// Try to get account-specific config first (direct lookup, then case-insensitive fallback)
|
||||
let accountConfig = accounts?.[normalizedAccountId];
|
||||
if (!accountConfig && accounts) {
|
||||
for (const key of Object.keys(accounts)) {
|
||||
if (normalizeAccountId(key) === normalizedAccountId) {
|
||||
accountConfig = accounts[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deep merge: account-specific values override top-level values, preserving
|
||||
// nested object inheritance (dm, actions, groups) so partial overrides work.
|
||||
const matrix = accountConfig ? deepMergeConfig(matrixBase, accountConfig) : matrixBase;
|
||||
|
||||
const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER);
|
||||
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
|
||||
const accessToken = clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
|
||||
const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
|
||||
const register =
|
||||
parseOptionalBoolean(matrix.register) ?? parseOptionalBoolean(env.MATRIX_REGISTER) ?? false;
|
||||
const deviceId = clean(matrix.deviceId) || clean(env.MATRIX_DEVICE_ID) || undefined;
|
||||
const deviceName = clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined;
|
||||
const initialSyncLimit =
|
||||
typeof matrix.initialSyncLimit === "number"
|
||||
@@ -119,22 +67,30 @@ export function resolveMatrixConfig(
|
||||
userId,
|
||||
accessToken,
|
||||
password,
|
||||
register,
|
||||
deviceId,
|
||||
deviceName,
|
||||
initialSyncLimit,
|
||||
encryption,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-account function for backward compatibility - resolves default account config.
|
||||
*/
|
||||
export function resolveMatrixConfig(
|
||||
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): MatrixResolvedConfig {
|
||||
return resolveMatrixConfigForAccount(cfg, DEFAULT_ACCOUNT_ID, env);
|
||||
}
|
||||
|
||||
export async function resolveMatrixAuth(params?: {
|
||||
cfg?: CoreConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
accountId?: string | null;
|
||||
}): Promise<MatrixAuth> {
|
||||
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
||||
const env = params?.env ?? process.env;
|
||||
const resolved = resolveMatrixConfig(cfg, env);
|
||||
const registerFromConfig = cfg.channels?.matrix?.register === true;
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, params?.accountId, env);
|
||||
if (!resolved.homeserver) {
|
||||
throw new Error("Matrix homeserver is required (matrix.homeserver)");
|
||||
}
|
||||
@@ -146,7 +102,8 @@ export async function resolveMatrixAuth(params?: {
|
||||
touchMatrixCredentials,
|
||||
} = await import("../credentials.js");
|
||||
|
||||
const cached = loadMatrixCredentials(env);
|
||||
const accountId = params?.accountId;
|
||||
const cached = loadMatrixCredentials(env, accountId);
|
||||
const cachedCredentials =
|
||||
cached &&
|
||||
credentialsMatchConfig(cached, {
|
||||
@@ -156,84 +113,44 @@ export async function resolveMatrixAuth(params?: {
|
||||
? cached
|
||||
: null;
|
||||
|
||||
if (registerFromConfig) {
|
||||
if (!resolved.userId) {
|
||||
throw new Error("Matrix userId is required when matrix.register=true");
|
||||
}
|
||||
if (!resolved.password) {
|
||||
throw new Error("Matrix password is required when matrix.register=true");
|
||||
}
|
||||
await prepareMatrixRegisterMode({
|
||||
cfg,
|
||||
homeserver: resolved.homeserver,
|
||||
userId: resolved.userId,
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
// If we have an access token, we can fetch userId via whoami if not provided
|
||||
if (resolved.accessToken && !registerFromConfig) {
|
||||
if (resolved.accessToken) {
|
||||
let userId = resolved.userId;
|
||||
const hasMatchingCachedToken = cachedCredentials?.accessToken === resolved.accessToken;
|
||||
let knownDeviceId = hasMatchingCachedToken
|
||||
? cachedCredentials?.deviceId || resolved.deviceId
|
||||
: resolved.deviceId;
|
||||
|
||||
if (!userId || !knownDeviceId) {
|
||||
// Fetch whoami when we need to resolve userId and/or deviceId from token auth.
|
||||
if (!userId) {
|
||||
// Fetch userId from access token via whoami
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
|
||||
const whoami = (await tempClient.doRequest("GET", "/_matrix/client/v3/account/whoami")) as {
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
};
|
||||
if (!userId) {
|
||||
const fetchedUserId = whoami.user_id?.trim();
|
||||
if (!fetchedUserId) {
|
||||
throw new Error("Matrix whoami did not return user_id");
|
||||
}
|
||||
userId = fetchedUserId;
|
||||
}
|
||||
if (!knownDeviceId) {
|
||||
knownDeviceId = whoami.device_id?.trim() || resolved.deviceId;
|
||||
}
|
||||
}
|
||||
|
||||
const shouldRefreshCachedCredentials =
|
||||
!cachedCredentials ||
|
||||
!hasMatchingCachedToken ||
|
||||
cachedCredentials.userId !== userId ||
|
||||
(cachedCredentials.deviceId || undefined) !== knownDeviceId;
|
||||
if (shouldRefreshCachedCredentials) {
|
||||
saveMatrixCredentials({
|
||||
homeserver: resolved.homeserver,
|
||||
userId,
|
||||
accessToken: resolved.accessToken,
|
||||
deviceId: knownDeviceId,
|
||||
});
|
||||
} else if (hasMatchingCachedToken) {
|
||||
touchMatrixCredentials(env);
|
||||
const whoami = await tempClient.getUserId();
|
||||
userId = whoami;
|
||||
// Save the credentials with the fetched userId
|
||||
saveMatrixCredentials(
|
||||
{
|
||||
homeserver: resolved.homeserver,
|
||||
userId,
|
||||
accessToken: resolved.accessToken,
|
||||
},
|
||||
env,
|
||||
accountId,
|
||||
);
|
||||
} else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
|
||||
touchMatrixCredentials(env, accountId);
|
||||
}
|
||||
return {
|
||||
homeserver: resolved.homeserver,
|
||||
userId,
|
||||
accessToken: resolved.accessToken,
|
||||
password: resolved.password,
|
||||
deviceId: knownDeviceId,
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
encryption: resolved.encryption,
|
||||
};
|
||||
}
|
||||
|
||||
if (cachedCredentials && !registerFromConfig) {
|
||||
touchMatrixCredentials(env);
|
||||
if (cachedCredentials) {
|
||||
touchMatrixCredentials(env, accountId);
|
||||
return {
|
||||
homeserver: cachedCredentials.homeserver,
|
||||
userId: cachedCredentials.userId,
|
||||
accessToken: cachedCredentials.accessToken,
|
||||
password: resolved.password,
|
||||
deviceId: cachedCredentials.deviceId || resolved.deviceId,
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
encryption: resolved.encryption,
|
||||
@@ -250,78 +167,53 @@ export async function resolveMatrixAuth(params?: {
|
||||
);
|
||||
}
|
||||
|
||||
// Login with password using the same hardened request path as other Matrix HTTP calls.
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
const loginClient = new MatrixClient(resolved.homeserver, "");
|
||||
let login: {
|
||||
// Login with password using HTTP API
|
||||
const loginResponse = await fetch(`${resolved.homeserver}/_matrix/client/v3/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "m.login.password",
|
||||
identifier: { type: "m.id.user", user: resolved.userId },
|
||||
password: resolved.password,
|
||||
initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!loginResponse.ok) {
|
||||
const errorText = await loginResponse.text();
|
||||
throw new Error(`Matrix login failed: ${errorText}`);
|
||||
}
|
||||
|
||||
const login = (await loginResponse.json()) as {
|
||||
access_token?: string;
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
};
|
||||
try {
|
||||
login = (await loginClient.doRequest("POST", "/_matrix/client/v3/login", undefined, {
|
||||
type: "m.login.password",
|
||||
identifier: { type: "m.id.user", user: resolved.userId },
|
||||
password: resolved.password,
|
||||
device_id: resolved.deviceId,
|
||||
initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
|
||||
})) as {
|
||||
access_token?: string;
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
};
|
||||
} catch (loginErr) {
|
||||
if (!resolved.register) {
|
||||
throw loginErr;
|
||||
}
|
||||
try {
|
||||
login = await registerMatrixPasswordAccount({
|
||||
homeserver: resolved.homeserver,
|
||||
userId: resolved.userId,
|
||||
password: resolved.password,
|
||||
deviceId: resolved.deviceId,
|
||||
deviceName: resolved.deviceName,
|
||||
});
|
||||
} catch (registerErr) {
|
||||
const loginMessage = loginErr instanceof Error ? loginErr.message : String(loginErr);
|
||||
const registerMessage =
|
||||
registerErr instanceof Error ? registerErr.message : String(registerErr);
|
||||
throw new Error(
|
||||
`Matrix login failed (${loginMessage}) and account registration failed (${registerMessage})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const accessToken = login.access_token?.trim();
|
||||
if (!accessToken) {
|
||||
throw new Error("Matrix login/registration did not return an access token");
|
||||
throw new Error("Matrix login did not return an access token");
|
||||
}
|
||||
|
||||
const auth: MatrixAuth = {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: login.user_id ?? resolved.userId,
|
||||
accessToken,
|
||||
password: resolved.password,
|
||||
deviceId: login.device_id ?? resolved.deviceId,
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
encryption: resolved.encryption,
|
||||
};
|
||||
|
||||
saveMatrixCredentials({
|
||||
homeserver: auth.homeserver,
|
||||
userId: auth.userId,
|
||||
accessToken: auth.accessToken,
|
||||
deviceId: auth.deviceId,
|
||||
});
|
||||
|
||||
if (registerFromConfig) {
|
||||
await finalizeMatrixRegisterConfigAfterSuccess({
|
||||
saveMatrixCredentials(
|
||||
{
|
||||
homeserver: auth.homeserver,
|
||||
userId: auth.userId,
|
||||
deviceId: auth.deviceId,
|
||||
});
|
||||
}
|
||||
accessToken: auth.accessToken,
|
||||
deviceId: login.device_id,
|
||||
},
|
||||
env,
|
||||
accountId,
|
||||
);
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import fs from "node:fs";
|
||||
import { MatrixClient } from "../sdk.js";
|
||||
import type { IStorageProvider, ICryptoStorageProvider } from "@vector-im/matrix-bot-sdk";
|
||||
import {
|
||||
LogService,
|
||||
MatrixClient,
|
||||
SimpleFsStorageProvider,
|
||||
RustSdkCryptoStorageProvider,
|
||||
} from "@vector-im/matrix-bot-sdk";
|
||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||
import {
|
||||
maybeMigrateLegacyStorage,
|
||||
@@ -7,50 +13,111 @@ import {
|
||||
writeStorageMeta,
|
||||
} from "./storage.js";
|
||||
|
||||
function sanitizeUserIdList(input: unknown, label: string): string[] {
|
||||
if (input == null) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(input)) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
`Expected ${label} list to be an array, got ${typeof input}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
const filtered = input.filter(
|
||||
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
|
||||
);
|
||||
if (filtered.length !== input.length) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
`Dropping ${input.length - filtered.length} invalid ${label} entries from sync payload`,
|
||||
);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export async function createMatrixClient(params: {
|
||||
homeserver: string;
|
||||
userId?: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
password?: string;
|
||||
deviceId?: string;
|
||||
encryption?: boolean;
|
||||
localTimeoutMs?: number;
|
||||
initialSyncLimit?: number;
|
||||
accountId?: string | null;
|
||||
}): Promise<MatrixClient> {
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
const env = process.env;
|
||||
const userId = params.userId?.trim() || "unknown";
|
||||
const matrixClientUserId = params.userId?.trim() || undefined;
|
||||
|
||||
// Create storage provider
|
||||
const storagePaths = resolveMatrixStoragePaths({
|
||||
homeserver: params.homeserver,
|
||||
userId,
|
||||
userId: params.userId,
|
||||
accessToken: params.accessToken,
|
||||
accountId: params.accountId,
|
||||
env,
|
||||
});
|
||||
maybeMigrateLegacyStorage({ storagePaths, env });
|
||||
fs.mkdirSync(storagePaths.rootDir, { recursive: true });
|
||||
const storage: IStorageProvider = new SimpleFsStorageProvider(storagePaths.storagePath);
|
||||
|
||||
// Create crypto storage if encryption is enabled
|
||||
let cryptoStorage: ICryptoStorageProvider | undefined;
|
||||
if (params.encryption) {
|
||||
fs.mkdirSync(storagePaths.cryptoPath, { recursive: true });
|
||||
|
||||
try {
|
||||
const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs");
|
||||
cryptoStorage = new RustSdkCryptoStorageProvider(storagePaths.cryptoPath, StoreType.Sqlite);
|
||||
} catch (err) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
"Failed to initialize crypto storage, E2EE disabled:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
writeStorageMeta({
|
||||
storagePaths,
|
||||
homeserver: params.homeserver,
|
||||
userId,
|
||||
userId: params.userId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
|
||||
const cryptoDatabasePrefix = `openclaw-matrix-${storagePaths.accountKey}-${storagePaths.tokenHash}`;
|
||||
const client = new MatrixClient(params.homeserver, params.accessToken, storage, cryptoStorage);
|
||||
|
||||
return new MatrixClient(params.homeserver, params.accessToken, undefined, undefined, {
|
||||
userId: matrixClientUserId,
|
||||
password: params.password,
|
||||
deviceId: params.deviceId,
|
||||
encryption: params.encryption,
|
||||
localTimeoutMs: params.localTimeoutMs,
|
||||
initialSyncLimit: params.initialSyncLimit,
|
||||
recoveryKeyPath: storagePaths.recoveryKeyPath,
|
||||
idbSnapshotPath: storagePaths.idbSnapshotPath,
|
||||
cryptoDatabasePrefix,
|
||||
});
|
||||
if (client.crypto) {
|
||||
const originalUpdateSyncData = client.crypto.updateSyncData.bind(client.crypto);
|
||||
client.crypto.updateSyncData = async (
|
||||
toDeviceMessages,
|
||||
otkCounts,
|
||||
unusedFallbackKeyAlgs,
|
||||
changedDeviceLists,
|
||||
leftDeviceLists,
|
||||
) => {
|
||||
const safeChanged = sanitizeUserIdList(changedDeviceLists, "changed device list");
|
||||
const safeLeft = sanitizeUserIdList(leftDeviceLists, "left device list");
|
||||
try {
|
||||
return await originalUpdateSyncData(
|
||||
toDeviceMessages,
|
||||
otkCounts,
|
||||
unusedFallbackKeyAlgs,
|
||||
safeChanged,
|
||||
safeLeft,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = typeof err === "string" ? err : err instanceof Error ? err.message : "";
|
||||
if (message.includes("Expect value to be String")) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
"Ignoring malformed device list entries during crypto sync",
|
||||
message,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConsoleLogger, LogService } from "../sdk/logger.js";
|
||||
import { ConsoleLogger, LogService } from "@vector-im/matrix-bot-sdk";
|
||||
|
||||
let matrixSdkLoggingConfigured = false;
|
||||
const matrixSdkBaseLogger = new ConsoleLogger();
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import * as runtimeModule from "../../runtime.js";
|
||||
import {
|
||||
finalizeMatrixRegisterConfigAfterSuccess,
|
||||
prepareMatrixRegisterMode,
|
||||
resetPreparedMatrixRegisterModesForTests,
|
||||
} from "./register-mode.js";
|
||||
|
||||
describe("matrix register mode helpers", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
resetPreparedMatrixRegisterModesForTests();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("moves existing matrix state into a .bak snapshot before fresh registration", async () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-register-mode-"));
|
||||
tempDirs.push(stateDir);
|
||||
const credentialsDir = path.join(stateDir, "credentials", "matrix");
|
||||
const accountsDir = path.join(credentialsDir, "accounts");
|
||||
fs.mkdirSync(accountsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(credentialsDir, "credentials.json"), '{"accessToken":"old"}\n');
|
||||
fs.writeFileSync(path.join(accountsDir, "dummy.txt"), "old-state\n");
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
register: true,
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const backupDir = await prepareMatrixRegisterMode({
|
||||
cfg,
|
||||
homeserver: "https://matrix.gumadeiras.com",
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(backupDir).toBeTruthy();
|
||||
expect(fs.existsSync(path.join(credentialsDir, "credentials.json"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(credentialsDir, "accounts"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(backupDir as string, "credentials.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(backupDir as string, "accounts", "dummy.txt"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(backupDir as string, "matrix-config.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("updates matrix config after successful register mode auth", async () => {
|
||||
const writeConfigFile = vi.fn(async () => {});
|
||||
vi.spyOn(runtimeModule, "getMatrixRuntime").mockReturnValue({
|
||||
config: {
|
||||
loadConfig: () =>
|
||||
({
|
||||
channels: {
|
||||
matrix: {
|
||||
register: true,
|
||||
accessToken: "stale-token",
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
},
|
||||
},
|
||||
}) as CoreConfig,
|
||||
writeConfigFile,
|
||||
},
|
||||
} as never);
|
||||
|
||||
const updated = await finalizeMatrixRegisterConfigAfterSuccess({
|
||||
homeserver: "https://matrix.gumadeiras.com",
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
deviceId: "DEVICE123",
|
||||
});
|
||||
expect(updated).toBe(true);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: expect.objectContaining({
|
||||
matrix: expect.objectContaining({
|
||||
register: false,
|
||||
homeserver: "https://matrix.gumadeiras.com",
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
deviceId: "DEVICE123",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const written = writeConfigFile.mock.calls[0]?.[0] as CoreConfig;
|
||||
expect(written.channels?.matrix?.accessToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { resolveMatrixCredentialsDir } from "../credentials.js";
|
||||
|
||||
const preparedRegisterKeys = new Set<string>();
|
||||
|
||||
function resolveStateDirFromEnv(env: NodeJS.ProcessEnv): string {
|
||||
try {
|
||||
return getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||
} catch {
|
||||
// fall through to deterministic fallback for tests/early init
|
||||
}
|
||||
const override = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
|
||||
if (override) {
|
||||
if (override.startsWith("~")) {
|
||||
const expanded = override.replace(/^~(?=$|[\\/])/, os.homedir());
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
return path.resolve(override);
|
||||
}
|
||||
return path.join(os.homedir(), ".openclaw");
|
||||
}
|
||||
|
||||
function buildRegisterKey(params: { homeserver: string; userId: string }): string {
|
||||
return `${params.homeserver.trim().toLowerCase()}|${params.userId.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
function buildBackupDirName(now = new Date()): string {
|
||||
const ts = now.toISOString().replace(/[:.]/g, "-");
|
||||
const suffix = Math.random().toString(16).slice(2, 8);
|
||||
return `${ts}-${suffix}`;
|
||||
}
|
||||
|
||||
export async function prepareMatrixRegisterMode(params: {
|
||||
cfg: CoreConfig;
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<string | null> {
|
||||
const env = params.env ?? process.env;
|
||||
const registerKey = buildRegisterKey({
|
||||
homeserver: params.homeserver,
|
||||
userId: params.userId,
|
||||
});
|
||||
if (preparedRegisterKeys.has(registerKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stateDir = resolveStateDirFromEnv(env);
|
||||
const credentialsDir = resolveMatrixCredentialsDir(env, stateDir);
|
||||
if (!fs.existsSync(credentialsDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(credentialsDir).filter((name) => name !== ".bak");
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const backupRoot = path.join(credentialsDir, ".bak");
|
||||
fs.mkdirSync(backupRoot, { recursive: true });
|
||||
const backupDir = path.join(backupRoot, buildBackupDirName());
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
|
||||
const matrixConfig = params.cfg.channels?.matrix ?? {};
|
||||
fs.writeFileSync(
|
||||
path.join(backupDir, "matrix-config.json"),
|
||||
JSON.stringify(matrixConfig, null, 2).trimEnd().concat("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
for (const entry of entries) {
|
||||
fs.renameSync(path.join(credentialsDir, entry), path.join(backupDir, entry));
|
||||
}
|
||||
|
||||
preparedRegisterKeys.add(registerKey);
|
||||
return backupDir;
|
||||
}
|
||||
|
||||
export async function finalizeMatrixRegisterConfigAfterSuccess(params: {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
deviceId?: string;
|
||||
}): Promise<boolean> {
|
||||
let runtime: ReturnType<typeof getMatrixRuntime> | null = null;
|
||||
try {
|
||||
runtime = getMatrixRuntime();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cfg = runtime.config.loadConfig() as CoreConfig;
|
||||
if (cfg.channels?.matrix?.register !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const matrixCfg = cfg.channels?.matrix ?? {};
|
||||
const nextMatrix: Record<string, unknown> = {
|
||||
...matrixCfg,
|
||||
register: false,
|
||||
homeserver: params.homeserver,
|
||||
userId: params.userId,
|
||||
...(params.deviceId?.trim() ? { deviceId: params.deviceId.trim() } : {}),
|
||||
};
|
||||
// Registration mode should continue relying on password + cached credentials, not stale inline token.
|
||||
delete nextMatrix.accessToken;
|
||||
|
||||
const next: CoreConfig = {
|
||||
...cfg,
|
||||
channels: {
|
||||
...(cfg.channels ?? {}),
|
||||
matrix: nextMatrix as CoreConfig["channels"]["matrix"],
|
||||
},
|
||||
};
|
||||
|
||||
await runtime.config.writeConfigFile(next as never);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resetPreparedMatrixRegisterModesForTests(): void {
|
||||
preparedRegisterKeys.clear();
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import type { MatrixAuth } from "./types.js";
|
||||
import { LogService } from "../sdk/logger.js";
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { LogService } from "@vector-im/matrix-bot-sdk";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { resolveMatrixAuth } from "./config.js";
|
||||
import { createMatrixClient } from "./create-client.js";
|
||||
import { DEFAULT_ACCOUNT_KEY } from "./storage.js";
|
||||
import type { MatrixAuth } from "./types.js";
|
||||
|
||||
type SharedMatrixClientState = {
|
||||
client: MatrixClient;
|
||||
@@ -13,17 +14,19 @@ type SharedMatrixClientState = {
|
||||
cryptoReady: boolean;
|
||||
};
|
||||
|
||||
let sharedClientState: SharedMatrixClientState | null = null;
|
||||
let sharedClientPromise: Promise<SharedMatrixClientState> | null = null;
|
||||
let sharedClientStartPromise: Promise<void> | null = null;
|
||||
// Support multiple accounts with separate clients
|
||||
const sharedClientStates = new Map<string, SharedMatrixClientState>();
|
||||
const sharedClientPromises = new Map<string, Promise<SharedMatrixClientState>>();
|
||||
const sharedClientStartPromises = new Map<string, Promise<void>>();
|
||||
|
||||
function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string {
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
return [
|
||||
auth.homeserver,
|
||||
auth.userId,
|
||||
auth.accessToken,
|
||||
auth.encryption ? "e2ee" : "plain",
|
||||
accountId ?? DEFAULT_ACCOUNT_KEY,
|
||||
normalizedAccountId || DEFAULT_ACCOUNT_KEY,
|
||||
].join("|");
|
||||
}
|
||||
|
||||
@@ -36,11 +39,8 @@ async function createSharedMatrixClient(params: {
|
||||
homeserver: params.auth.homeserver,
|
||||
userId: params.auth.userId,
|
||||
accessToken: params.auth.accessToken,
|
||||
password: params.auth.password,
|
||||
deviceId: params.auth.deviceId,
|
||||
encryption: params.auth.encryption,
|
||||
localTimeoutMs: params.timeoutMs,
|
||||
initialSyncLimit: params.auth.initialSyncLimit,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
return {
|
||||
@@ -60,11 +60,13 @@ async function ensureSharedClientStarted(params: {
|
||||
if (params.state.started) {
|
||||
return;
|
||||
}
|
||||
if (sharedClientStartPromise) {
|
||||
await sharedClientStartPromise;
|
||||
const key = params.state.key;
|
||||
const existingStartPromise = sharedClientStartPromises.get(key);
|
||||
if (existingStartPromise) {
|
||||
await existingStartPromise;
|
||||
return;
|
||||
}
|
||||
sharedClientStartPromise = (async () => {
|
||||
const startPromise = (async () => {
|
||||
const client = params.state.client;
|
||||
|
||||
// Initialize crypto if enabled
|
||||
@@ -72,7 +74,9 @@ async function ensureSharedClientStarted(params: {
|
||||
try {
|
||||
const joinedRooms = await client.getJoinedRooms();
|
||||
if (client.crypto) {
|
||||
await client.crypto.prepare(joinedRooms);
|
||||
await (client.crypto as { prepare: (rooms?: string[]) => Promise<void> }).prepare(
|
||||
joinedRooms,
|
||||
);
|
||||
params.state.cryptoReady = true;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -83,10 +87,11 @@ async function ensureSharedClientStarted(params: {
|
||||
await client.start();
|
||||
params.state.started = true;
|
||||
})();
|
||||
sharedClientStartPromises.set(key, startPromise);
|
||||
try {
|
||||
await sharedClientStartPromise;
|
||||
await startPromise;
|
||||
} finally {
|
||||
sharedClientStartPromise = null;
|
||||
sharedClientStartPromises.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,48 +105,51 @@ export async function resolveSharedMatrixClient(
|
||||
accountId?: string | null;
|
||||
} = {},
|
||||
): Promise<MatrixClient> {
|
||||
const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env }));
|
||||
const key = buildSharedClientKey(auth, params.accountId);
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const auth =
|
||||
params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env, accountId }));
|
||||
const key = buildSharedClientKey(auth, accountId);
|
||||
const shouldStart = params.startClient !== false;
|
||||
|
||||
if (sharedClientState?.key === key) {
|
||||
// Check if we already have a client for this key
|
||||
const existingState = sharedClientStates.get(key);
|
||||
if (existingState) {
|
||||
if (shouldStart) {
|
||||
await ensureSharedClientStarted({
|
||||
state: sharedClientState,
|
||||
state: existingState,
|
||||
timeoutMs: params.timeoutMs,
|
||||
initialSyncLimit: auth.initialSyncLimit,
|
||||
encryption: auth.encryption,
|
||||
});
|
||||
}
|
||||
return sharedClientState.client;
|
||||
return existingState.client;
|
||||
}
|
||||
|
||||
if (sharedClientPromise) {
|
||||
const pending = await sharedClientPromise;
|
||||
if (pending.key === key) {
|
||||
if (shouldStart) {
|
||||
await ensureSharedClientStarted({
|
||||
state: pending,
|
||||
timeoutMs: params.timeoutMs,
|
||||
initialSyncLimit: auth.initialSyncLimit,
|
||||
encryption: auth.encryption,
|
||||
});
|
||||
}
|
||||
return pending.client;
|
||||
// Check if there's a pending creation for this key
|
||||
const existingPromise = sharedClientPromises.get(key);
|
||||
if (existingPromise) {
|
||||
const pending = await existingPromise;
|
||||
if (shouldStart) {
|
||||
await ensureSharedClientStarted({
|
||||
state: pending,
|
||||
timeoutMs: params.timeoutMs,
|
||||
initialSyncLimit: auth.initialSyncLimit,
|
||||
encryption: auth.encryption,
|
||||
});
|
||||
}
|
||||
pending.client.stop();
|
||||
sharedClientState = null;
|
||||
sharedClientPromise = null;
|
||||
return pending.client;
|
||||
}
|
||||
|
||||
sharedClientPromise = createSharedMatrixClient({
|
||||
// Create a new client for this account
|
||||
const createPromise = createSharedMatrixClient({
|
||||
auth,
|
||||
timeoutMs: params.timeoutMs,
|
||||
accountId: params.accountId,
|
||||
accountId,
|
||||
});
|
||||
sharedClientPromises.set(key, createPromise);
|
||||
try {
|
||||
const created = await sharedClientPromise;
|
||||
sharedClientState = created;
|
||||
const created = await createPromise;
|
||||
sharedClientStates.set(key, created);
|
||||
if (shouldStart) {
|
||||
await ensureSharedClientStarted({
|
||||
state: created,
|
||||
@@ -152,7 +160,7 @@ export async function resolveSharedMatrixClient(
|
||||
}
|
||||
return created.client;
|
||||
} finally {
|
||||
sharedClientPromise = null;
|
||||
sharedClientPromises.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,13 +169,33 @@ export async function waitForMatrixSync(_params: {
|
||||
timeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
// matrix-js-sdk handles sync lifecycle in start() for this integration.
|
||||
// @vector-im/matrix-bot-sdk handles sync internally in start()
|
||||
// This is kept for API compatibility but is essentially a no-op now
|
||||
}
|
||||
|
||||
export function stopSharedClient(): void {
|
||||
if (sharedClientState) {
|
||||
sharedClientState.client.stop();
|
||||
sharedClientState = null;
|
||||
export function stopSharedClient(key?: string): void {
|
||||
if (key) {
|
||||
// Stop a specific client
|
||||
const state = sharedClientStates.get(key);
|
||||
if (state) {
|
||||
state.client.stop();
|
||||
sharedClientStates.delete(key);
|
||||
}
|
||||
} else {
|
||||
// Stop all clients (backward compatible behavior)
|
||||
for (const state of sharedClientStates.values()) {
|
||||
state.client.stop();
|
||||
}
|
||||
sharedClientStates.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the shared client for a specific account.
|
||||
* Use this instead of stopSharedClient() when shutting down a single account
|
||||
* to avoid stopping all accounts.
|
||||
*/
|
||||
export function stopSharedClientForAccount(auth: MatrixAuth, accountId?: string | null): void {
|
||||
const key = buildSharedClientKey(auth, normalizeAccountId(accountId));
|
||||
stopSharedClient(key);
|
||||
}
|
||||
|
||||
@@ -39,8 +39,8 @@ function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): {
|
||||
} {
|
||||
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||
return {
|
||||
storagePath: path.join(stateDir, "credentials", "matrix", "bot-storage.json"),
|
||||
cryptoPath: path.join(stateDir, "credentials", "matrix", "crypto"),
|
||||
storagePath: path.join(stateDir, "matrix", "bot-storage.json"),
|
||||
cryptoPath: path.join(stateDir, "matrix", "crypto"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,7 +59,6 @@ export function resolveMatrixStoragePaths(params: {
|
||||
const tokenHash = hashAccessToken(params.accessToken);
|
||||
const rootDir = path.join(
|
||||
stateDir,
|
||||
"credentials",
|
||||
"matrix",
|
||||
"accounts",
|
||||
accountKey,
|
||||
@@ -71,8 +70,6 @@ export function resolveMatrixStoragePaths(params: {
|
||||
storagePath: path.join(rootDir, "bot-storage.json"),
|
||||
cryptoPath: path.join(rootDir, "crypto"),
|
||||
metaPath: path.join(rootDir, STORAGE_META_FILENAME),
|
||||
recoveryKeyPath: path.join(rootDir, "recovery-key.json"),
|
||||
idbSnapshotPath: path.join(rootDir, "crypto-idb-snapshot.json"),
|
||||
accountKey,
|
||||
tokenHash,
|
||||
};
|
||||
|
||||
@@ -2,9 +2,7 @@ export type MatrixResolvedConfig = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken?: string;
|
||||
deviceId?: string;
|
||||
password?: string;
|
||||
register?: boolean;
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: number;
|
||||
encryption?: boolean;
|
||||
@@ -21,8 +19,6 @@ export type MatrixAuth = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
password?: string;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: number;
|
||||
encryption?: boolean;
|
||||
@@ -33,8 +29,6 @@ export type MatrixStoragePaths = {
|
||||
storagePath: string;
|
||||
cryptoPath: string;
|
||||
metaPath: string;
|
||||
recoveryKeyPath: string;
|
||||
idbSnapshotPath: string;
|
||||
accountKey: string;
|
||||
tokenHash: string;
|
||||
};
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { runPluginCommandWithTimeout, type RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
|
||||
const REQUIRED_MATRIX_PACKAGES = ["matrix-js-sdk", "@matrix-org/matrix-sdk-crypto-nodejs"];
|
||||
|
||||
function resolveMissingMatrixPackages(): string[] {
|
||||
try {
|
||||
const req = createRequire(import.meta.url);
|
||||
return REQUIRED_MATRIX_PACKAGES.filter((pkg) => {
|
||||
try {
|
||||
req.resolve(pkg);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return [...REQUIRED_MATRIX_PACKAGES];
|
||||
}
|
||||
}
|
||||
const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk";
|
||||
|
||||
export function isMatrixSdkAvailable(): boolean {
|
||||
return resolveMissingMatrixPackages().length === 0;
|
||||
try {
|
||||
const req = createRequire(import.meta.url);
|
||||
req.resolve(MATRIX_SDK_PACKAGE);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePluginRoot(): string {
|
||||
@@ -32,85 +21,6 @@ function resolvePluginRoot(): string {
|
||||
return path.resolve(currentDir, "..", "..");
|
||||
}
|
||||
|
||||
type CommandResult = {
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
async function runFixedCommandWithTimeout(params: {
|
||||
argv: string[];
|
||||
cwd: string;
|
||||
timeoutMs: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<CommandResult> {
|
||||
return await new Promise((resolve) => {
|
||||
const [command, ...args] = params.argv;
|
||||
if (!command) {
|
||||
resolve({
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "command is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const proc = spawn(command, args, {
|
||||
cwd: params.cwd,
|
||||
env: { ...process.env, ...params.env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
const finalize = (result: CommandResult) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
proc.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
proc.stderr?.on("data", (chunk: Buffer | string) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
timer = setTimeout(() => {
|
||||
proc.kill("SIGKILL");
|
||||
finalize({
|
||||
code: 124,
|
||||
stdout,
|
||||
stderr: stderr || `command timed out after ${params.timeoutMs}ms`,
|
||||
});
|
||||
}, params.timeoutMs);
|
||||
|
||||
proc.on("error", (err) => {
|
||||
finalize({
|
||||
code: 1,
|
||||
stdout,
|
||||
stderr: err.message,
|
||||
});
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
finalize({
|
||||
code: code ?? 1,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureMatrixSdkInstalled(params: {
|
||||
runtime: RuntimeEnv;
|
||||
confirm?: (message: string) => Promise<boolean>;
|
||||
@@ -120,13 +30,9 @@ export async function ensureMatrixSdkInstalled(params: {
|
||||
}
|
||||
const confirm = params.confirm;
|
||||
if (confirm) {
|
||||
const ok = await confirm(
|
||||
"Matrix requires matrix-js-sdk and @matrix-org/matrix-sdk-crypto-nodejs. Install now?",
|
||||
);
|
||||
const ok = await confirm("Matrix requires @vector-im/matrix-bot-sdk. Install now?");
|
||||
if (!ok) {
|
||||
throw new Error(
|
||||
"Matrix requires matrix-js-sdk and @matrix-org/matrix-sdk-crypto-nodejs (install dependencies first).",
|
||||
);
|
||||
throw new Error("Matrix requires @vector-im/matrix-bot-sdk (install dependencies first).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +41,7 @@ export async function ensureMatrixSdkInstalled(params: {
|
||||
? ["pnpm", "install"]
|
||||
: ["npm", "install", "--omit=dev", "--silent"];
|
||||
params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`);
|
||||
const result = await runFixedCommandWithTimeout({
|
||||
const result = await runPluginCommandWithTimeout({
|
||||
argv: command,
|
||||
cwd: root,
|
||||
timeoutMs: 300_000,
|
||||
@@ -147,11 +53,8 @@ export async function ensureMatrixSdkInstalled(params: {
|
||||
);
|
||||
}
|
||||
if (!isMatrixSdkAvailable()) {
|
||||
const missing = resolveMissingMatrixPackages();
|
||||
throw new Error(
|
||||
missing.length > 0
|
||||
? `Matrix dependency install completed but required packages are still missing: ${missing.join(", ")}`
|
||||
: "Matrix dependency install completed but Matrix dependencies are still missing.",
|
||||
"Matrix dependency install completed but @vector-im/matrix-bot-sdk is still missing.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { setMatrixRuntime } from "../../runtime.js";
|
||||
import { registerMatrixAutoJoin } from "./auto-join.js";
|
||||
|
||||
type InviteHandler = (roomId: string, inviteEvent: unknown) => Promise<void>;
|
||||
|
||||
function createClientStub() {
|
||||
let inviteHandler: InviteHandler | null = null;
|
||||
const client = {
|
||||
on: vi.fn((eventName: string, listener: unknown) => {
|
||||
if (eventName === "room.invite") {
|
||||
inviteHandler = listener as InviteHandler;
|
||||
}
|
||||
return client;
|
||||
}),
|
||||
joinRoom: vi.fn(async () => {}),
|
||||
getRoomStateEvent: vi.fn(async () => ({})),
|
||||
} as unknown as import("../sdk.js").MatrixClient;
|
||||
|
||||
return {
|
||||
client,
|
||||
getInviteHandler: () => inviteHandler,
|
||||
joinRoom: (client as unknown as { joinRoom: ReturnType<typeof vi.fn> }).joinRoom,
|
||||
getRoomStateEvent: (client as unknown as { getRoomStateEvent: ReturnType<typeof vi.fn> })
|
||||
.getRoomStateEvent,
|
||||
};
|
||||
}
|
||||
|
||||
describe("registerMatrixAutoJoin", () => {
|
||||
beforeEach(() => {
|
||||
setMatrixRuntime({
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
});
|
||||
|
||||
it("joins all invites when autoJoin=always", async () => {
|
||||
const { client, getInviteHandler, joinRoom } = createClientStub();
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
autoJoin: "always",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
registerMatrixAutoJoin({
|
||||
client,
|
||||
cfg,
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as import("openclaw/plugin-sdk").RuntimeEnv,
|
||||
});
|
||||
|
||||
const inviteHandler = getInviteHandler();
|
||||
expect(inviteHandler).toBeTruthy();
|
||||
await inviteHandler!("!room:example.org", {});
|
||||
|
||||
expect(joinRoom).toHaveBeenCalledWith("!room:example.org");
|
||||
});
|
||||
|
||||
it("ignores invites outside allowlist when autoJoin=allowlist", async () => {
|
||||
const { client, getInviteHandler, joinRoom, getRoomStateEvent } = createClientStub();
|
||||
getRoomStateEvent.mockResolvedValue({
|
||||
alias: "#other:example.org",
|
||||
alt_aliases: ["#else:example.org"],
|
||||
});
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
autoJoin: "allowlist",
|
||||
autoJoinAllowlist: ["#allowed:example.org"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
registerMatrixAutoJoin({
|
||||
client,
|
||||
cfg,
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as import("openclaw/plugin-sdk").RuntimeEnv,
|
||||
});
|
||||
|
||||
const inviteHandler = getInviteHandler();
|
||||
expect(inviteHandler).toBeTruthy();
|
||||
await inviteHandler!("!room:example.org", {});
|
||||
|
||||
expect(joinRoom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("joins invite when alias matches allowlist", async () => {
|
||||
const { client, getInviteHandler, joinRoom, getRoomStateEvent } = createClientStub();
|
||||
getRoomStateEvent.mockResolvedValue({
|
||||
alias: "#allowed:example.org",
|
||||
alt_aliases: ["#backup:example.org"],
|
||||
});
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
autoJoin: "allowlist",
|
||||
autoJoinAllowlist: [" #allowed:example.org "],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
registerMatrixAutoJoin({
|
||||
client,
|
||||
cfg,
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as import("openclaw/plugin-sdk").RuntimeEnv,
|
||||
});
|
||||
|
||||
const inviteHandler = getInviteHandler();
|
||||
expect(inviteHandler).toBeTruthy();
|
||||
await inviteHandler!("!room:example.org", {});
|
||||
|
||||
expect(joinRoom).toHaveBeenCalledWith("!room:example.org");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { AutojoinRoomsMixin } from "@vector-im/matrix-bot-sdk";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
|
||||
export function registerMatrixAutoJoin(params: {
|
||||
client: MatrixClient;
|
||||
@@ -17,52 +18,47 @@ export function registerMatrixAutoJoin(params: {
|
||||
runtime.log?.(message);
|
||||
};
|
||||
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
|
||||
const autoJoinAllowlist = new Set(
|
||||
(cfg.channels?.matrix?.autoJoinAllowlist ?? [])
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? [];
|
||||
|
||||
if (autoJoin === "off") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoJoin === "always") {
|
||||
// Use the built-in autojoin mixin for "always" mode
|
||||
AutojoinRoomsMixin.setupOnClient(client);
|
||||
logVerbose("matrix: auto-join enabled for all invites");
|
||||
} else {
|
||||
logVerbose("matrix: auto-join enabled for allowlist invites");
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle invites directly so both "always" and "allowlist" modes share the same path.
|
||||
// For "allowlist" mode, handle invites manually
|
||||
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
|
||||
if (autoJoin === "allowlist") {
|
||||
let alias: string | undefined;
|
||||
let altAliases: string[] = [];
|
||||
try {
|
||||
const aliasState = await client
|
||||
.getRoomStateEvent(roomId, "m.room.canonical_alias", "")
|
||||
.catch(() => null);
|
||||
alias = aliasState && typeof aliasState.alias === "string" ? aliasState.alias : undefined;
|
||||
altAliases =
|
||||
aliasState && Array.isArray(aliasState.alt_aliases)
|
||||
? aliasState.alt_aliases
|
||||
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
if (autoJoin !== "allowlist") {
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed =
|
||||
autoJoinAllowlist.has("*") ||
|
||||
autoJoinAllowlist.has(roomId) ||
|
||||
(alias ? autoJoinAllowlist.has(alias) : false) ||
|
||||
altAliases.some((value) => autoJoinAllowlist.has(value));
|
||||
// Get room alias if available
|
||||
let alias: string | undefined;
|
||||
let altAliases: string[] = [];
|
||||
try {
|
||||
const aliasState = await client
|
||||
.getRoomStateEvent(roomId, "m.room.canonical_alias", "")
|
||||
.catch(() => null);
|
||||
alias = aliasState?.alias;
|
||||
altAliases = Array.isArray(aliasState?.alt_aliases) ? aliasState.alt_aliases : [];
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
|
||||
return;
|
||||
}
|
||||
const allowed =
|
||||
autoJoinAllowlist.includes("*") ||
|
||||
autoJoinAllowlist.includes(roomId) ||
|
||||
(alias ? autoJoinAllowlist.includes(alias) : false) ||
|
||||
altAliases.some((value) => autoJoinAllowlist.includes(value));
|
||||
|
||||
if (!allowed) {
|
||||
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
|
||||
type DirectMessageCheck = {
|
||||
roomId: string;
|
||||
|
||||
141
extensions/matrix/src/matrix/monitor/events.test.ts
Normal file
141
extensions/matrix/src/matrix/monitor/events.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixAuth } from "../client.js";
|
||||
import { registerMatrixMonitorEvents } from "./events.js";
|
||||
import type { MatrixRawEvent } from "./types.js";
|
||||
|
||||
const sendReadReceiptMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
|
||||
vi.mock("../send.js", () => ({
|
||||
sendReadReceiptMatrix: (...args: unknown[]) => sendReadReceiptMatrixMock(...args),
|
||||
}));
|
||||
|
||||
describe("registerMatrixMonitorEvents", () => {
|
||||
beforeEach(() => {
|
||||
sendReadReceiptMatrixMock.mockClear();
|
||||
});
|
||||
|
||||
function createHarness(options?: { getUserId?: ReturnType<typeof vi.fn> }) {
|
||||
const handlers = new Map<string, (...args: unknown[]) => void>();
|
||||
const getUserId = options?.getUserId ?? vi.fn().mockResolvedValue("@bot:example.org");
|
||||
const client = {
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
handlers.set(event, handler);
|
||||
}),
|
||||
getUserId,
|
||||
crypto: undefined,
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const onRoomMessage = vi.fn();
|
||||
const logVerboseMessage = vi.fn();
|
||||
const logger = {
|
||||
warn: vi.fn(),
|
||||
} as unknown as RuntimeLogger;
|
||||
|
||||
registerMatrixMonitorEvents({
|
||||
client,
|
||||
auth: { encryption: false } as MatrixAuth,
|
||||
logVerboseMessage,
|
||||
warnedEncryptedRooms: new Set<string>(),
|
||||
warnedCryptoMissingRooms: new Set<string>(),
|
||||
logger,
|
||||
formatNativeDependencyHint: (() =>
|
||||
"") as PluginRuntime["system"]["formatNativeDependencyHint"],
|
||||
onRoomMessage,
|
||||
});
|
||||
|
||||
const roomMessageHandler = handlers.get("room.message");
|
||||
if (!roomMessageHandler) {
|
||||
throw new Error("missing room.message handler");
|
||||
}
|
||||
|
||||
return { client, getUserId, onRoomMessage, roomMessageHandler, logVerboseMessage };
|
||||
}
|
||||
|
||||
it("sends read receipt immediately for non-self messages", async () => {
|
||||
const { client, onRoomMessage, roomMessageHandler } = createHarness();
|
||||
const event = {
|
||||
event_id: "$e1",
|
||||
sender: "@alice:example.org",
|
||||
} as MatrixRawEvent;
|
||||
|
||||
roomMessageHandler("!room:example.org", event);
|
||||
|
||||
expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event);
|
||||
await vi.waitFor(() => {
|
||||
expect(sendReadReceiptMatrixMock).toHaveBeenCalledWith("!room:example.org", "$e1", client);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send read receipts for self messages", async () => {
|
||||
const { onRoomMessage, roomMessageHandler } = createHarness();
|
||||
const event = {
|
||||
event_id: "$e2",
|
||||
sender: "@bot:example.org",
|
||||
} as MatrixRawEvent;
|
||||
|
||||
roomMessageHandler("!room:example.org", event);
|
||||
await vi.waitFor(() => {
|
||||
expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event);
|
||||
});
|
||||
expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips receipt when message lacks sender or event id", async () => {
|
||||
const { onRoomMessage, roomMessageHandler } = createHarness();
|
||||
const event = {
|
||||
sender: "@alice:example.org",
|
||||
} as MatrixRawEvent;
|
||||
|
||||
roomMessageHandler("!room:example.org", event);
|
||||
await vi.waitFor(() => {
|
||||
expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event);
|
||||
});
|
||||
expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caches self user id across messages", async () => {
|
||||
const { getUserId, roomMessageHandler } = createHarness();
|
||||
const first = { event_id: "$e3", sender: "@alice:example.org" } as MatrixRawEvent;
|
||||
const second = { event_id: "$e4", sender: "@bob:example.org" } as MatrixRawEvent;
|
||||
|
||||
roomMessageHandler("!room:example.org", first);
|
||||
roomMessageHandler("!room:example.org", second);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(sendReadReceiptMatrixMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(getUserId).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("logs and continues when sending read receipt fails", async () => {
|
||||
sendReadReceiptMatrixMock.mockRejectedValueOnce(new Error("network boom"));
|
||||
const { roomMessageHandler, onRoomMessage, logVerboseMessage } = createHarness();
|
||||
const event = { event_id: "$e5", sender: "@alice:example.org" } as MatrixRawEvent;
|
||||
|
||||
roomMessageHandler("!room:example.org", event);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event);
|
||||
expect(logVerboseMessage).toHaveBeenCalledWith(
|
||||
expect.stringContaining("matrix: early read receipt failed"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips read receipts if self-user lookup fails", async () => {
|
||||
const { roomMessageHandler, onRoomMessage, getUserId } = createHarness({
|
||||
getUserId: vi.fn().mockRejectedValue(new Error("cannot resolve self")),
|
||||
});
|
||||
const event = { event_id: "$e6", sender: "@alice:example.org" } as MatrixRawEvent;
|
||||
|
||||
roomMessageHandler("!room:example.org", event);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event);
|
||||
});
|
||||
expect(getUserId).toHaveBeenCalledTimes(1);
|
||||
expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,43 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk";
|
||||
import type { MatrixAuth } from "../client.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { sendReadReceiptMatrix } from "../send.js";
|
||||
import type { MatrixRawEvent } from "./types.js";
|
||||
import { EventType } from "./types.js";
|
||||
|
||||
function createSelfUserIdResolver(client: Pick<MatrixClient, "getUserId">) {
|
||||
let selfUserId: string | undefined;
|
||||
let selfUserIdLookup: Promise<string | undefined> | undefined;
|
||||
|
||||
return async (): Promise<string | undefined> => {
|
||||
if (selfUserId) {
|
||||
return selfUserId;
|
||||
}
|
||||
if (!selfUserIdLookup) {
|
||||
selfUserIdLookup = client
|
||||
.getUserId()
|
||||
.then((userId) => {
|
||||
selfUserId = userId;
|
||||
return userId;
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (!selfUserId) {
|
||||
selfUserIdLookup = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
return await selfUserIdLookup;
|
||||
};
|
||||
}
|
||||
|
||||
export function registerMatrixMonitorEvents(params: {
|
||||
client: MatrixClient;
|
||||
auth: MatrixAuth;
|
||||
logVerboseMessage: (message: string) => void;
|
||||
warnedEncryptedRooms: Set<string>;
|
||||
warnedCryptoMissingRooms: Set<string>;
|
||||
logger: { warn: (meta: Record<string, unknown>, message: string) => void };
|
||||
logger: RuntimeLogger;
|
||||
formatNativeDependencyHint: PluginRuntime["system"]["formatNativeDependencyHint"];
|
||||
onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise<void>;
|
||||
}): void {
|
||||
@@ -25,7 +52,26 @@ export function registerMatrixMonitorEvents(params: {
|
||||
onRoomMessage,
|
||||
} = params;
|
||||
|
||||
client.on("room.message", onRoomMessage);
|
||||
const resolveSelfUserId = createSelfUserIdResolver(client);
|
||||
client.on("room.message", (roomId: string, event: MatrixRawEvent) => {
|
||||
const eventId = event?.event_id;
|
||||
const senderId = event?.sender;
|
||||
if (eventId && senderId) {
|
||||
void (async () => {
|
||||
const currentSelfUserId = await resolveSelfUserId();
|
||||
if (!currentSelfUserId || senderId === currentSelfUserId) {
|
||||
return;
|
||||
}
|
||||
await sendReadReceiptMatrix(roomId, eventId, client).catch((err) => {
|
||||
logVerboseMessage(
|
||||
`matrix: early read receipt failed room=${roomId} id=${eventId}: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
onRoomMessage(roomId, event);
|
||||
});
|
||||
|
||||
client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => {
|
||||
const eventId = event?.event_id ?? "unknown";
|
||||
@@ -42,10 +88,11 @@ export function registerMatrixMonitorEvents(params: {
|
||||
client.on(
|
||||
"room.failed_decryption",
|
||||
async (roomId: string, event: MatrixRawEvent, error: Error) => {
|
||||
logger.warn(
|
||||
{ roomId, eventId: event.event_id, error: error.message },
|
||||
"Failed to decrypt message",
|
||||
);
|
||||
logger.warn("Failed to decrypt message", {
|
||||
roomId,
|
||||
eventId: event.event_id,
|
||||
error: error.message,
|
||||
});
|
||||
logVerboseMessage(
|
||||
`matrix: failed decrypt room=${roomId} id=${event.event_id ?? "unknown"} error=${error.message}`,
|
||||
);
|
||||
@@ -76,7 +123,7 @@ export function registerMatrixMonitorEvents(params: {
|
||||
warnedEncryptedRooms.add(roomId);
|
||||
const warning =
|
||||
"matrix: encrypted event received without encryption enabled; set channels.matrix.encryption=true and verify the device to decrypt";
|
||||
logger.warn({ roomId }, warning);
|
||||
logger.warn(warning, { roomId });
|
||||
}
|
||||
if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) {
|
||||
warnedCryptoMissingRooms.add(roomId);
|
||||
@@ -86,7 +133,7 @@ export function registerMatrixMonitorEvents(params: {
|
||||
downloadCommand: "node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js",
|
||||
});
|
||||
const warning = `matrix: encryption enabled but crypto is unavailable; ${hint}`;
|
||||
logger.warn({ roomId }, warning);
|
||||
logger.warn(warning, { roomId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import {
|
||||
createReplyPrefixOptions,
|
||||
createTypingCallbacks,
|
||||
@@ -5,23 +6,19 @@ import {
|
||||
logInboundDrop,
|
||||
logTypingFailure,
|
||||
resolveControlCommandGate,
|
||||
type PluginRuntime,
|
||||
type RuntimeEnv,
|
||||
type RuntimeLogger,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { CoreConfig, ReplyToMode } from "../../types.js";
|
||||
import type { LocationMessageEventContent, MatrixClient } from "../sdk.js";
|
||||
import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
|
||||
import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js";
|
||||
import { fetchEventSummary } from "../actions/summary.js";
|
||||
import {
|
||||
formatPollAsText,
|
||||
isPollStartType,
|
||||
parsePollStartContent,
|
||||
type PollStartContent,
|
||||
} from "../poll-types.js";
|
||||
import {
|
||||
reactMatrixMessage,
|
||||
sendMessageMatrix,
|
||||
sendReadReceiptMatrix,
|
||||
sendTypingMatrix,
|
||||
} from "../send.js";
|
||||
import { reactMatrixMessage, sendMessageMatrix, sendTypingMatrix } from "../send.js";
|
||||
import {
|
||||
normalizeMatrixAllowList,
|
||||
resolveMatrixAllowListMatch,
|
||||
@@ -33,38 +30,19 @@ import { resolveMentions } from "./mentions.js";
|
||||
import { deliverMatrixReplies } from "./replies.js";
|
||||
import { resolveMatrixRoomConfig } from "./rooms.js";
|
||||
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
|
||||
import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
|
||||
import { EventType, RelationType } from "./types.js";
|
||||
|
||||
export type MatrixMonitorHandlerParams = {
|
||||
client: MatrixClient;
|
||||
core: {
|
||||
logging: {
|
||||
shouldLogVerbose: () => boolean;
|
||||
};
|
||||
channel: (typeof import("openclaw/plugin-sdk"))["channel"];
|
||||
system: {
|
||||
enqueueSystemEvent: (
|
||||
text: string,
|
||||
meta: { sessionKey?: string | null; contextKey?: string | null },
|
||||
) => void;
|
||||
};
|
||||
};
|
||||
core: PluginRuntime;
|
||||
cfg: CoreConfig;
|
||||
runtime: RuntimeEnv;
|
||||
logger: {
|
||||
info: (message: string | Record<string, unknown>, ...meta: unknown[]) => void;
|
||||
warn: (meta: Record<string, unknown>, message: string) => void;
|
||||
};
|
||||
logger: RuntimeLogger;
|
||||
logVerboseMessage: (message: string) => void;
|
||||
allowFrom: string[];
|
||||
roomsConfig: CoreConfig["channels"] extends { matrix?: infer MatrixConfig }
|
||||
? MatrixConfig extends { groups?: infer Groups }
|
||||
? Groups
|
||||
: Record<string, unknown> | undefined
|
||||
: Record<string, unknown> | undefined;
|
||||
mentionRegexes: ReturnType<
|
||||
(typeof import("openclaw/plugin-sdk"))["channel"]["mentions"]["buildMentionRegexes"]
|
||||
>;
|
||||
roomsConfig: Record<string, MatrixRoomConfig> | undefined;
|
||||
mentionRegexes: ReturnType<PluginRuntime["channel"]["mentions"]["buildMentionRegexes"]>;
|
||||
groupPolicy: "open" | "allowlist" | "disabled";
|
||||
replyToMode: ReplyToMode;
|
||||
threadReplies: "off" | "inbound" | "always";
|
||||
@@ -85,6 +63,7 @@ export type MatrixMonitorHandlerParams = {
|
||||
roomId: string,
|
||||
) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>;
|
||||
getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
|
||||
accountId?: string | null;
|
||||
};
|
||||
|
||||
export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) {
|
||||
@@ -110,18 +89,19 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
directTracker,
|
||||
getRoomInfo,
|
||||
getMemberDisplayName,
|
||||
accountId,
|
||||
} = params;
|
||||
|
||||
return async (roomId: string, event: MatrixRawEvent) => {
|
||||
try {
|
||||
const eventType = event.type;
|
||||
if (eventType === EventType.RoomMessageEncrypted) {
|
||||
// Encrypted payloads are emitted separately after decryption.
|
||||
// Encrypted messages are decrypted automatically by @vector-im/matrix-bot-sdk with crypto enabled
|
||||
return;
|
||||
}
|
||||
|
||||
const isPollEvent = isPollStartType(eventType);
|
||||
const locationContent = event.content as LocationMessageEventContent;
|
||||
const locationContent = event.content as unknown as LocationMessageEventContent;
|
||||
const isLocationEvent =
|
||||
eventType === EventType.Location ||
|
||||
(eventType === EventType.RoomMessage && locationContent.msgtype === EventType.Location);
|
||||
@@ -159,9 +139,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
const roomName = roomInfo.name;
|
||||
const roomAliases = [roomInfo.canonicalAlias ?? "", ...roomInfo.altAliases].filter(Boolean);
|
||||
|
||||
let content = event.content as RoomMessageEventContent;
|
||||
let content = event.content as unknown as RoomMessageEventContent;
|
||||
if (isPollEvent) {
|
||||
const pollStartContent = event.content as PollStartContent;
|
||||
const pollStartContent = event.content as unknown as PollStartContent;
|
||||
const pollSummary = parsePollStartContent(pollStartContent);
|
||||
if (pollSummary) {
|
||||
pollSummary.eventId = event.event_id ?? "";
|
||||
@@ -233,9 +213,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
}
|
||||
|
||||
const senderName = await getMemberDisplayName(roomId, senderId);
|
||||
const storeAllowFrom = await core.channel.pairing
|
||||
.readAllowFromStore("matrix")
|
||||
.catch(() => []);
|
||||
const storeAllowFrom =
|
||||
dmPolicy === "allowlist"
|
||||
? []
|
||||
: await core.channel.pairing.readAllowFromStore("matrix").catch(() => []);
|
||||
const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]);
|
||||
const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
|
||||
const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom);
|
||||
@@ -435,7 +416,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
hasControlCommandInMessage;
|
||||
const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention;
|
||||
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
|
||||
logger.info({ roomId, reason: "no-mention" }, "skipping room message");
|
||||
logger.info("skipping room message", { roomId, reason: "no-mention" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -446,19 +427,69 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
threadReplies,
|
||||
messageId,
|
||||
threadRootId,
|
||||
isThreadRoot: false, // Raw event payload does not carry explicit thread-root metadata.
|
||||
isThreadRoot: false, // @vector-im/matrix-bot-sdk doesn't have this info readily available
|
||||
});
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
const baseRoute = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "matrix",
|
||||
accountId,
|
||||
peer: {
|
||||
kind: isDirectMessage ? "dm" : "channel",
|
||||
kind: isDirectMessage ? "direct" : "channel",
|
||||
id: isDirectMessage ? senderId : roomId,
|
||||
},
|
||||
});
|
||||
|
||||
const route = {
|
||||
...baseRoute,
|
||||
sessionKey: threadRootId
|
||||
? `${baseRoute.sessionKey}:thread:${threadRootId}`
|
||||
: baseRoute.sessionKey,
|
||||
};
|
||||
|
||||
let threadStarterBody: string | undefined;
|
||||
let threadLabel: string | undefined;
|
||||
let parentSessionKey: string | undefined;
|
||||
|
||||
if (threadRootId) {
|
||||
const existingSession = core.channel.session.readSessionUpdatedAt({
|
||||
storePath: core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: baseRoute.agentId,
|
||||
}),
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
|
||||
if (existingSession === undefined) {
|
||||
try {
|
||||
const rootEvent = await fetchEventSummary(client, roomId, threadRootId);
|
||||
if (rootEvent?.body) {
|
||||
const rootSenderName = rootEvent.sender
|
||||
? await getMemberDisplayName(roomId, rootEvent.sender)
|
||||
: undefined;
|
||||
|
||||
threadStarterBody = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Matrix",
|
||||
from: rootSenderName ?? rootEvent.sender ?? "Unknown",
|
||||
timestamp: rootEvent.timestamp,
|
||||
envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
|
||||
body: rootEvent.body,
|
||||
});
|
||||
|
||||
threadLabel = `Matrix thread in ${roomName ?? roomId}`;
|
||||
parentSessionKey = baseRoute.sessionKey;
|
||||
}
|
||||
} catch (err) {
|
||||
logVerboseMessage(
|
||||
`matrix: failed to fetch thread root ${threadRootId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
||||
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
||||
const textWithId = threadRootId
|
||||
? `${bodyText}\n[matrix event id: ${messageId} room: ${roomId} thread: ${threadRootId}]`
|
||||
: `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
@@ -479,13 +510,14 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined;
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
BodyForAgent: bodyText,
|
||||
RawBody: bodyText,
|
||||
CommandBody: bodyText,
|
||||
From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`,
|
||||
To: `room:${roomId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isDirectMessage ? "direct" : "channel",
|
||||
ChatType: threadRootId ? "thread" : isDirectMessage ? "direct" : "channel",
|
||||
ConversationLabel: envelopeFrom,
|
||||
SenderName: senderName,
|
||||
SenderId: senderId,
|
||||
@@ -508,6 +540,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
CommandSource: "text" as const,
|
||||
OriginatingChannel: "matrix" as const,
|
||||
OriginatingTo: `room:${roomId}`,
|
||||
ThreadStarterBody: threadStarterBody,
|
||||
ThreadLabel: threadLabel,
|
||||
ParentSessionKey: parentSessionKey,
|
||||
});
|
||||
|
||||
await core.channel.session.recordInboundSession({
|
||||
@@ -523,14 +558,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
}
|
||||
: undefined,
|
||||
onRecordError: (err) => {
|
||||
logger.warn(
|
||||
{
|
||||
error: String(err),
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
},
|
||||
"failed updating session meta",
|
||||
);
|
||||
logger.warn("failed updating session meta", {
|
||||
error: String(err),
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -565,14 +597,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageId) {
|
||||
sendReadReceiptMatrix(roomId, messageId, client).catch((err) => {
|
||||
logVerboseMessage(
|
||||
`matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
let didSendReply = false;
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
@@ -611,6 +635,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
...prefixOptions,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||
typingCallbacks,
|
||||
deliver: async (payload) => {
|
||||
await deliverMatrixReplies({
|
||||
replies: [payload],
|
||||
@@ -628,8 +653,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
onReplyStart: typingCallbacks.onReplyStart,
|
||||
onIdle: typingCallbacks.onIdle,
|
||||
});
|
||||
|
||||
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { format } from "node:util";
|
||||
import {
|
||||
createLoggerBackedRuntime,
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
mergeAllowlist,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
@@ -48,18 +48,11 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
}
|
||||
|
||||
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
|
||||
const formatRuntimeMessage = (...args: Parameters<RuntimeEnv["log"]>) => format(...args);
|
||||
const runtime: RuntimeEnv = opts.runtime ?? {
|
||||
log: (...args) => {
|
||||
logger.info(formatRuntimeMessage(...args));
|
||||
},
|
||||
error: (...args) => {
|
||||
logger.error(formatRuntimeMessage(...args));
|
||||
},
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
};
|
||||
const runtime: RuntimeEnv =
|
||||
opts.runtime ??
|
||||
createLoggerBackedRuntime({
|
||||
logger,
|
||||
});
|
||||
const logVerboseMessage = (message: string) => {
|
||||
if (!core.logging.shouldLogVerbose()) {
|
||||
return;
|
||||
@@ -326,7 +319,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
});
|
||||
logVerboseMessage("matrix: client started");
|
||||
|
||||
// Shared client is already started via resolveSharedMatrixClient.
|
||||
// @vector-im/matrix-bot-sdk client is already started via resolveSharedMatrixClient
|
||||
logger.info(`matrix: logged in as ${auth.userId}`);
|
||||
|
||||
// If E2EE is enabled, trigger device verification
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { LocationMessageEventContent } from "@vector-im/matrix-bot-sdk";
|
||||
import {
|
||||
formatLocationText,
|
||||
toLocationContext,
|
||||
type NormalizedLocation,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { LocationMessageEventContent } from "../sdk.js";
|
||||
import { EventType } from "./types.js";
|
||||
|
||||
export type MatrixLocationPayload = {
|
||||
|
||||
@@ -22,14 +22,12 @@ describe("downloadMatrixMedia", () => {
|
||||
setMatrixRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
it("decrypts encrypted media when file payloads are present", async () => {
|
||||
function makeEncryptedMediaFixture() {
|
||||
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
|
||||
|
||||
const client = {
|
||||
crypto: { decryptMedia },
|
||||
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
||||
} as unknown as import("../sdk.js").MatrixClient;
|
||||
|
||||
} as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
|
||||
const file = {
|
||||
url: "mxc://example/file",
|
||||
key: {
|
||||
@@ -43,6 +41,11 @@ describe("downloadMatrixMedia", () => {
|
||||
hashes: { sha256: "hash" },
|
||||
v: "v2",
|
||||
};
|
||||
return { decryptMedia, client, file };
|
||||
}
|
||||
|
||||
it("decrypts encrypted media when file payloads are present", async () => {
|
||||
const { decryptMedia, client, file } = makeEncryptedMediaFixture();
|
||||
|
||||
const result = await downloadMatrixMedia({
|
||||
client,
|
||||
@@ -64,26 +67,7 @@ describe("downloadMatrixMedia", () => {
|
||||
});
|
||||
|
||||
it("rejects encrypted media that exceeds maxBytes before decrypting", async () => {
|
||||
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
|
||||
|
||||
const client = {
|
||||
crypto: { decryptMedia },
|
||||
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
||||
} as unknown as import("../sdk.js").MatrixClient;
|
||||
|
||||
const file = {
|
||||
url: "mxc://example/file",
|
||||
key: {
|
||||
kty: "oct",
|
||||
key_ops: ["encrypt", "decrypt"],
|
||||
alg: "A256CTR",
|
||||
k: "secret",
|
||||
ext: true,
|
||||
},
|
||||
iv: "iv",
|
||||
hashes: { sha256: "hash" },
|
||||
v: "v2",
|
||||
};
|
||||
const { decryptMedia, client, file } = makeEncryptedMediaFixture();
|
||||
|
||||
await expect(
|
||||
downloadMatrixMedia({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
|
||||
// Type for encrypted file info
|
||||
@@ -21,7 +21,7 @@ async function fetchMatrixMediaBuffer(params: {
|
||||
mxcUrl: string;
|
||||
maxBytes: number;
|
||||
}): Promise<{ buffer: Buffer; headerType?: string } | null> {
|
||||
// The client wrapper exposes mxcToHttp for Matrix media URIs.
|
||||
// @vector-im/matrix-bot-sdk provides mxcToHttp helper
|
||||
const url = params.client.mxcToHttp(params.mxcUrl);
|
||||
if (!url) {
|
||||
return null;
|
||||
@@ -44,7 +44,7 @@ async function fetchMatrixMediaBuffer(params: {
|
||||
|
||||
/**
|
||||
* Download and decrypt encrypted media from a Matrix room.
|
||||
* Uses the Matrix crypto adapter's decryptMedia helper.
|
||||
* Uses @vector-im/matrix-bot-sdk's decryptMedia which handles both download and decryption.
|
||||
*/
|
||||
async function fetchEncryptedMediaBuffer(params: {
|
||||
client: MatrixClient;
|
||||
|
||||
@@ -108,6 +108,58 @@ describe("deliverMatrixReplies", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("skips reasoning-only replies with Reasoning prefix", async () => {
|
||||
await deliverMatrixReplies({
|
||||
replies: [
|
||||
{ text: "Reasoning:\nThe user wants X because Y.", replyToId: "r1" },
|
||||
{ text: "Here is the answer.", replyToId: "r2" },
|
||||
],
|
||||
roomId: "room:reason",
|
||||
client: {} as MatrixClient,
|
||||
runtime: runtimeEnv,
|
||||
textLimit: 4000,
|
||||
replyToMode: "first",
|
||||
});
|
||||
|
||||
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageMatrixMock.mock.calls[0]?.[1]).toBe("Here is the answer.");
|
||||
});
|
||||
|
||||
it("skips reasoning-only replies with thinking tags", async () => {
|
||||
await deliverMatrixReplies({
|
||||
replies: [
|
||||
{ text: "<thinking>internal chain of thought</thinking>", replyToId: "r1" },
|
||||
{ text: " <think>more reasoning</think> ", replyToId: "r2" },
|
||||
{ text: "<antthinking>hidden</antthinking>", replyToId: "r3" },
|
||||
{ text: "Visible reply", replyToId: "r4" },
|
||||
],
|
||||
roomId: "room:tags",
|
||||
client: {} as MatrixClient,
|
||||
runtime: runtimeEnv,
|
||||
textLimit: 4000,
|
||||
replyToMode: "all",
|
||||
});
|
||||
|
||||
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageMatrixMock.mock.calls[0]?.[1]).toBe("Visible reply");
|
||||
});
|
||||
|
||||
it("delivers all replies when none are reasoning-only", async () => {
|
||||
await deliverMatrixReplies({
|
||||
replies: [
|
||||
{ text: "First answer", replyToId: "r1" },
|
||||
{ text: "Second answer", replyToId: "r2" },
|
||||
],
|
||||
roomId: "room:normal",
|
||||
client: {} as MatrixClient,
|
||||
runtime: runtimeEnv,
|
||||
textLimit: 4000,
|
||||
replyToMode: "all",
|
||||
});
|
||||
|
||||
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("suppresses replyToId when threadId is set", async () => {
|
||||
chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|"));
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { sendMessageMatrix } from "../send.js";
|
||||
|
||||
@@ -41,6 +41,11 @@ export async function deliverMatrixReplies(params: {
|
||||
params.runtime.error?.("matrix reply missing text/media");
|
||||
continue;
|
||||
}
|
||||
// Skip pure reasoning messages so internal thinking traces are never delivered.
|
||||
if (reply.text && isReasoningOnlyMessage(reply.text)) {
|
||||
logVerbose("matrix reply is reasoning-only; skipping");
|
||||
continue;
|
||||
}
|
||||
const replyToIdRaw = reply.replyToId?.trim();
|
||||
const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw;
|
||||
const rawText = reply.text ?? "";
|
||||
@@ -98,3 +103,22 @@ export async function deliverMatrixReplies(params: {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const REASONING_PREFIX = "Reasoning:\n";
|
||||
const THINKING_TAG_RE = /^\s*<\s*(?:think(?:ing)?|thought|antthinking)\b/i;
|
||||
|
||||
/**
|
||||
* Detect messages that contain only reasoning/thinking content and no user-facing answer.
|
||||
* These are emitted by the agent when `includeReasoning` is active but should not
|
||||
* be forwarded to channels that do not support a dedicated reasoning lane.
|
||||
*/
|
||||
function isReasoningOnlyMessage(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith(REASONING_PREFIX)) {
|
||||
return true;
|
||||
}
|
||||
if (THINKING_TAG_RE.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
|
||||
export type MatrixRoomInfo = {
|
||||
name?: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Type for raw Matrix event payload consumed by thread helpers.
|
||||
// Type for raw Matrix event from @vector-im/matrix-bot-sdk
|
||||
type MatrixRawEvent = {
|
||||
event_id: string;
|
||||
sender: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { EncryptedFile, MatrixRawEvent, MessageEventContent } from "../sdk.js";
|
||||
import type { EncryptedFile, MessageEventContent } from "@vector-im/matrix-bot-sdk";
|
||||
|
||||
export const EventType = {
|
||||
RoomMessage: "m.room.message",
|
||||
@@ -12,6 +12,18 @@ export const RelationType = {
|
||||
Thread: "m.thread",
|
||||
} as const;
|
||||
|
||||
export type MatrixRawEvent = {
|
||||
event_id: string;
|
||||
sender: string;
|
||||
type: string;
|
||||
origin_server_ts: number;
|
||||
content: Record<string, unknown>;
|
||||
unsigned?: {
|
||||
age?: number;
|
||||
redacted_because?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type RoomMessageEventContent = MessageEventContent & {
|
||||
url?: string;
|
||||
file?: EncryptedFile;
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const createMatrixClientMock = vi.fn();
|
||||
const isBunRuntimeMock = vi.fn(() => false);
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args),
|
||||
isBunRuntime: () => isBunRuntimeMock(),
|
||||
}));
|
||||
|
||||
import { probeMatrix } from "./probe.js";
|
||||
|
||||
describe("probeMatrix", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isBunRuntimeMock.mockReturnValue(false);
|
||||
createMatrixClientMock.mockResolvedValue({
|
||||
getUserId: vi.fn(async () => "@bot:example.org"),
|
||||
});
|
||||
});
|
||||
|
||||
it("passes undefined userId when not provided", async () => {
|
||||
const result = await probeMatrix({
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok",
|
||||
timeoutMs: 1234,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(createMatrixClientMock).toHaveBeenCalledWith({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: undefined,
|
||||
accessToken: "tok",
|
||||
localTimeoutMs: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it("trims provided userId before client creation", async () => {
|
||||
await probeMatrix({
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok",
|
||||
userId: " @bot:example.org ",
|
||||
timeoutMs: 500,
|
||||
});
|
||||
|
||||
expect(createMatrixClientMock).toHaveBeenCalledWith({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok",
|
||||
localTimeoutMs: 500,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -42,14 +42,13 @@ export async function probeMatrix(params: {
|
||||
};
|
||||
}
|
||||
try {
|
||||
const inputUserId = params.userId?.trim() || undefined;
|
||||
const client = await createMatrixClient({
|
||||
homeserver: params.homeserver,
|
||||
userId: inputUserId,
|
||||
userId: params.userId ?? "",
|
||||
accessToken: params.accessToken,
|
||||
localTimeoutMs: params.timeoutMs,
|
||||
});
|
||||
// The client wrapper resolves user ID via whoami when needed.
|
||||
// @vector-im/matrix-bot-sdk uses getUserId() which calls whoami internally
|
||||
const userId = await client.getUserId();
|
||||
result.ok = true;
|
||||
result.userId = userId ?? null;
|
||||
|
||||
@@ -1,751 +0,0 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
class FakeMatrixEvent extends EventEmitter {
|
||||
private readonly roomId: string;
|
||||
private readonly eventId: string;
|
||||
private readonly sender: string;
|
||||
private readonly type: string;
|
||||
private readonly ts: number;
|
||||
private readonly content: Record<string, unknown>;
|
||||
private readonly stateKey?: string;
|
||||
private readonly unsigned?: {
|
||||
age?: number;
|
||||
redacted_because?: unknown;
|
||||
};
|
||||
private readonly decryptionFailure: boolean;
|
||||
|
||||
constructor(params: {
|
||||
roomId: string;
|
||||
eventId: string;
|
||||
sender: string;
|
||||
type: string;
|
||||
ts: number;
|
||||
content: Record<string, unknown>;
|
||||
stateKey?: string;
|
||||
unsigned?: {
|
||||
age?: number;
|
||||
redacted_because?: unknown;
|
||||
};
|
||||
decryptionFailure?: boolean;
|
||||
}) {
|
||||
super();
|
||||
this.roomId = params.roomId;
|
||||
this.eventId = params.eventId;
|
||||
this.sender = params.sender;
|
||||
this.type = params.type;
|
||||
this.ts = params.ts;
|
||||
this.content = params.content;
|
||||
this.stateKey = params.stateKey;
|
||||
this.unsigned = params.unsigned;
|
||||
this.decryptionFailure = params.decryptionFailure === true;
|
||||
}
|
||||
|
||||
getRoomId(): string {
|
||||
return this.roomId;
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return this.eventId;
|
||||
}
|
||||
|
||||
getSender(): string {
|
||||
return this.sender;
|
||||
}
|
||||
|
||||
getType(): string {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
getTs(): number {
|
||||
return this.ts;
|
||||
}
|
||||
|
||||
getContent(): Record<string, unknown> {
|
||||
return this.content;
|
||||
}
|
||||
|
||||
getUnsigned(): { age?: number; redacted_because?: unknown } {
|
||||
return this.unsigned ?? {};
|
||||
}
|
||||
|
||||
getStateKey(): string | undefined {
|
||||
return this.stateKey;
|
||||
}
|
||||
|
||||
isDecryptionFailure(): boolean {
|
||||
return this.decryptionFailure;
|
||||
}
|
||||
}
|
||||
|
||||
type MatrixJsClientStub = EventEmitter & {
|
||||
startClient: ReturnType<typeof vi.fn>;
|
||||
stopClient: ReturnType<typeof vi.fn>;
|
||||
initRustCrypto: ReturnType<typeof vi.fn>;
|
||||
getUserId: ReturnType<typeof vi.fn>;
|
||||
getDeviceId: ReturnType<typeof vi.fn>;
|
||||
getJoinedRooms: ReturnType<typeof vi.fn>;
|
||||
getJoinedRoomMembers: ReturnType<typeof vi.fn>;
|
||||
getStateEvent: ReturnType<typeof vi.fn>;
|
||||
getAccountData: ReturnType<typeof vi.fn>;
|
||||
setAccountData: ReturnType<typeof vi.fn>;
|
||||
getRoomIdForAlias: ReturnType<typeof vi.fn>;
|
||||
sendMessage: ReturnType<typeof vi.fn>;
|
||||
sendEvent: ReturnType<typeof vi.fn>;
|
||||
sendStateEvent: ReturnType<typeof vi.fn>;
|
||||
redactEvent: ReturnType<typeof vi.fn>;
|
||||
getProfileInfo: ReturnType<typeof vi.fn>;
|
||||
joinRoom: ReturnType<typeof vi.fn>;
|
||||
mxcUrlToHttp: ReturnType<typeof vi.fn>;
|
||||
uploadContent: ReturnType<typeof vi.fn>;
|
||||
fetchRoomEvent: ReturnType<typeof vi.fn>;
|
||||
sendTyping: ReturnType<typeof vi.fn>;
|
||||
getRoom: ReturnType<typeof vi.fn>;
|
||||
getRooms: ReturnType<typeof vi.fn>;
|
||||
getCrypto: ReturnType<typeof vi.fn>;
|
||||
decryptEventIfNeeded: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createMatrixJsClientStub(): MatrixJsClientStub {
|
||||
const client = new EventEmitter() as MatrixJsClientStub;
|
||||
client.startClient = vi.fn(async () => {});
|
||||
client.stopClient = vi.fn();
|
||||
client.initRustCrypto = vi.fn(async () => {});
|
||||
client.getUserId = vi.fn(() => "@bot:example.org");
|
||||
client.getDeviceId = vi.fn(() => "DEVICE123");
|
||||
client.getJoinedRooms = vi.fn(async () => ({ joined_rooms: [] }));
|
||||
client.getJoinedRoomMembers = vi.fn(async () => ({ joined: {} }));
|
||||
client.getStateEvent = vi.fn(async () => ({}));
|
||||
client.getAccountData = vi.fn(() => undefined);
|
||||
client.setAccountData = vi.fn(async () => {});
|
||||
client.getRoomIdForAlias = vi.fn(async () => ({ room_id: "!resolved:example.org" }));
|
||||
client.sendMessage = vi.fn(async () => ({ event_id: "$sent" }));
|
||||
client.sendEvent = vi.fn(async () => ({ event_id: "$sent-event" }));
|
||||
client.sendStateEvent = vi.fn(async () => ({ event_id: "$state" }));
|
||||
client.redactEvent = vi.fn(async () => ({ event_id: "$redact" }));
|
||||
client.getProfileInfo = vi.fn(async () => ({}));
|
||||
client.joinRoom = vi.fn(async () => ({}));
|
||||
client.mxcUrlToHttp = vi.fn(() => null);
|
||||
client.uploadContent = vi.fn(async () => ({ content_uri: "mxc://example/file" }));
|
||||
client.fetchRoomEvent = vi.fn(async () => ({}));
|
||||
client.sendTyping = vi.fn(async () => {});
|
||||
client.getRoom = vi.fn(() => ({ hasEncryptionStateEvent: () => false }));
|
||||
client.getRooms = vi.fn(() => []);
|
||||
client.getCrypto = vi.fn(() => undefined);
|
||||
client.decryptEventIfNeeded = vi.fn(async () => {});
|
||||
return client;
|
||||
}
|
||||
|
||||
let matrixJsClient = createMatrixJsClientStub();
|
||||
let lastCreateClientOpts: Record<string, unknown> | null = null;
|
||||
|
||||
vi.mock("matrix-js-sdk", () => ({
|
||||
ClientEvent: { Event: "event", Room: "Room" },
|
||||
MatrixEventEvent: { Decrypted: "decrypted" },
|
||||
createClient: vi.fn((opts: Record<string, unknown>) => {
|
||||
lastCreateClientOpts = opts;
|
||||
return matrixJsClient;
|
||||
}),
|
||||
}));
|
||||
|
||||
import { MatrixClient } from "./sdk.js";
|
||||
|
||||
describe("MatrixClient request hardening", () => {
|
||||
beforeEach(() => {
|
||||
matrixJsClient = createMatrixJsClientStub();
|
||||
lastCreateClientOpts = null;
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("blocks absolute endpoints unless explicitly allowed", async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response("{}", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
await expect(client.doRequest("GET", "https://matrix.example.org/start")).rejects.toThrow(
|
||||
"Absolute Matrix endpoint is blocked by default",
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks cross-protocol redirects when absolute endpoints are allowed", async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response("", {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: "http://evil.example.org/next",
|
||||
},
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
|
||||
await expect(
|
||||
client.doRequest("GET", "https://matrix.example.org/start", undefined, undefined, {
|
||||
allowAbsoluteEndpoint: true,
|
||||
}),
|
||||
).rejects.toThrow("Blocked cross-protocol redirect");
|
||||
});
|
||||
|
||||
it("strips authorization when redirect crosses origin", async () => {
|
||||
const calls: Array<{ url: string; headers: Headers }> = [];
|
||||
const fetchMock = vi.fn(async (url: URL | string, init?: RequestInit) => {
|
||||
calls.push({
|
||||
url: String(url),
|
||||
headers: new Headers(init?.headers),
|
||||
});
|
||||
if (calls.length === 1) {
|
||||
return new Response("", {
|
||||
status: 302,
|
||||
headers: { location: "https://cdn.example.org/next" },
|
||||
});
|
||||
}
|
||||
return new Response("{}", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
await client.doRequest("GET", "https://matrix.example.org/start", undefined, undefined, {
|
||||
allowAbsoluteEndpoint: true,
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0]?.url).toBe("https://matrix.example.org/start");
|
||||
expect(calls[0]?.headers.get("authorization")).toBe("Bearer token");
|
||||
expect(calls[1]?.url).toBe("https://cdn.example.org/next");
|
||||
expect(calls[1]?.headers.get("authorization")).toBeNull();
|
||||
});
|
||||
|
||||
it("aborts requests after timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.fn((_: URL | string, init?: RequestInit) => {
|
||||
return new Promise<Response>((_, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
reject(new Error("aborted"));
|
||||
});
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
localTimeoutMs: 25,
|
||||
});
|
||||
|
||||
const pending = client.doRequest("GET", "/_matrix/client/v3/account/whoami");
|
||||
const assertion = expect(pending).rejects.toThrow("aborted");
|
||||
await vi.advanceTimersByTimeAsync(30);
|
||||
|
||||
await assertion;
|
||||
});
|
||||
});
|
||||
|
||||
describe("MatrixClient event bridge", () => {
|
||||
beforeEach(() => {
|
||||
matrixJsClient = createMatrixJsClientStub();
|
||||
lastCreateClientOpts = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("emits room.message only after encrypted events decrypt", async () => {
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
const messageEvents: Array<{ roomId: string; type: string }> = [];
|
||||
|
||||
client.on("room.message", (roomId, event) => {
|
||||
messageEvents.push({ roomId, type: event.type });
|
||||
});
|
||||
|
||||
await client.start();
|
||||
|
||||
const encrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.encrypted",
|
||||
ts: Date.now(),
|
||||
content: {},
|
||||
});
|
||||
const decrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
matrixJsClient.emit("event", encrypted);
|
||||
expect(messageEvents).toHaveLength(0);
|
||||
|
||||
encrypted.emit("decrypted", decrypted);
|
||||
// Simulate a second normal event emission from the SDK after decryption.
|
||||
matrixJsClient.emit("event", decrypted);
|
||||
expect(messageEvents).toEqual([
|
||||
{
|
||||
roomId: "!room:example.org",
|
||||
type: "m.room.message",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits room.failed_decryption when decrypting fails", async () => {
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
const failed: string[] = [];
|
||||
const delivered: string[] = [];
|
||||
|
||||
client.on("room.failed_decryption", (_roomId, _event, error) => {
|
||||
failed.push(error.message);
|
||||
});
|
||||
client.on("room.message", (_roomId, event) => {
|
||||
delivered.push(event.type);
|
||||
});
|
||||
|
||||
await client.start();
|
||||
|
||||
const encrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.encrypted",
|
||||
ts: Date.now(),
|
||||
content: {},
|
||||
});
|
||||
const decrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
matrixJsClient.emit("event", encrypted);
|
||||
encrypted.emit("decrypted", decrypted, new Error("decrypt failed"));
|
||||
|
||||
expect(failed).toEqual(["decrypt failed"]);
|
||||
expect(delivered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("retries failed decryption and emits room.message after late key availability", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
const failed: string[] = [];
|
||||
const delivered: string[] = [];
|
||||
|
||||
client.on("room.failed_decryption", (_roomId, _event, error) => {
|
||||
failed.push(error.message);
|
||||
});
|
||||
client.on("room.message", (_roomId, event) => {
|
||||
delivered.push(event.type);
|
||||
});
|
||||
|
||||
const encrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.encrypted",
|
||||
ts: Date.now(),
|
||||
content: {},
|
||||
decryptionFailure: true,
|
||||
});
|
||||
const decrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
matrixJsClient.decryptEventIfNeeded = vi.fn(async () => {
|
||||
encrypted.emit("decrypted", decrypted);
|
||||
});
|
||||
|
||||
await client.start();
|
||||
matrixJsClient.emit("event", encrypted);
|
||||
encrypted.emit("decrypted", encrypted, new Error("missing room key"));
|
||||
|
||||
expect(failed).toEqual(["missing room key"]);
|
||||
expect(delivered).toHaveLength(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_600);
|
||||
|
||||
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(failed).toEqual(["missing room key"]);
|
||||
expect(delivered).toEqual(["m.room.message"]);
|
||||
});
|
||||
|
||||
it("retries failed decryptions immediately on crypto key update signals", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
encryption: true,
|
||||
});
|
||||
const failed: string[] = [];
|
||||
const delivered: string[] = [];
|
||||
const cryptoListeners = new Map<string, (...args: unknown[]) => void>();
|
||||
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
|
||||
cryptoListeners.set(eventName, listener);
|
||||
}),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(async () => {}),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
client.on("room.failed_decryption", (_roomId, _event, error) => {
|
||||
failed.push(error.message);
|
||||
});
|
||||
client.on("room.message", (_roomId, event) => {
|
||||
delivered.push(event.type);
|
||||
});
|
||||
|
||||
const encrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.encrypted",
|
||||
ts: Date.now(),
|
||||
content: {},
|
||||
decryptionFailure: true,
|
||||
});
|
||||
const decrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
},
|
||||
});
|
||||
matrixJsClient.decryptEventIfNeeded = vi.fn(async () => {
|
||||
encrypted.emit("decrypted", decrypted);
|
||||
});
|
||||
|
||||
await client.start();
|
||||
matrixJsClient.emit("event", encrypted);
|
||||
encrypted.emit("decrypted", encrypted, new Error("missing room key"));
|
||||
|
||||
expect(failed).toEqual(["missing room key"]);
|
||||
expect(delivered).toHaveLength(0);
|
||||
|
||||
const trigger = cryptoListeners.get("crypto.keyBackupDecryptionKeyCached");
|
||||
expect(trigger).toBeTypeOf("function");
|
||||
trigger?.();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(delivered).toEqual(["m.room.message"]);
|
||||
});
|
||||
|
||||
it("stops decryption retries after hitting retry cap", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
const failed: string[] = [];
|
||||
|
||||
client.on("room.failed_decryption", (_roomId, _event, error) => {
|
||||
failed.push(error.message);
|
||||
});
|
||||
|
||||
const encrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.encrypted",
|
||||
ts: Date.now(),
|
||||
content: {},
|
||||
decryptionFailure: true,
|
||||
});
|
||||
|
||||
matrixJsClient.decryptEventIfNeeded = vi.fn(async () => {
|
||||
throw new Error("still missing key");
|
||||
});
|
||||
|
||||
await client.start();
|
||||
matrixJsClient.emit("event", encrypted);
|
||||
encrypted.emit("decrypted", encrypted, new Error("missing room key"));
|
||||
|
||||
expect(failed).toEqual(["missing room key"]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200_000);
|
||||
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(8);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200_000);
|
||||
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
|
||||
it("does not start duplicate retries when crypto signals fire while retry is in-flight", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
encryption: true,
|
||||
});
|
||||
const delivered: string[] = [];
|
||||
const cryptoListeners = new Map<string, (...args: unknown[]) => void>();
|
||||
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
|
||||
cryptoListeners.set(eventName, listener);
|
||||
}),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(async () => {}),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
client.on("room.message", (_roomId, event) => {
|
||||
delivered.push(event.type);
|
||||
});
|
||||
|
||||
const encrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.encrypted",
|
||||
ts: Date.now(),
|
||||
content: {},
|
||||
decryptionFailure: true,
|
||||
});
|
||||
const decrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
let releaseRetry: (() => void) | null = null;
|
||||
matrixJsClient.decryptEventIfNeeded = vi.fn(
|
||||
async () =>
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseRetry = () => {
|
||||
encrypted.emit("decrypted", decrypted);
|
||||
resolve();
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
await client.start();
|
||||
matrixJsClient.emit("event", encrypted);
|
||||
encrypted.emit("decrypted", encrypted, new Error("missing room key"));
|
||||
|
||||
const trigger = cryptoListeners.get("crypto.keyBackupDecryptionKeyCached");
|
||||
expect(trigger).toBeTypeOf("function");
|
||||
trigger?.();
|
||||
trigger?.();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
releaseRetry?.();
|
||||
await Promise.resolve();
|
||||
expect(delivered).toEqual(["m.room.message"]);
|
||||
});
|
||||
|
||||
it("emits room.invite when a membership invite targets the current user", async () => {
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
const invites: string[] = [];
|
||||
|
||||
client.on("room.invite", (roomId) => {
|
||||
invites.push(roomId);
|
||||
});
|
||||
|
||||
await client.start();
|
||||
|
||||
const inviteMembership = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$invite",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.member",
|
||||
ts: Date.now(),
|
||||
stateKey: "@bot:example.org",
|
||||
content: {
|
||||
membership: "invite",
|
||||
},
|
||||
});
|
||||
|
||||
matrixJsClient.emit("event", inviteMembership);
|
||||
|
||||
expect(invites).toEqual(["!room:example.org"]);
|
||||
});
|
||||
|
||||
it("emits room.invite when SDK emits Room event with invite membership", async () => {
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
const invites: string[] = [];
|
||||
client.on("room.invite", (roomId) => {
|
||||
invites.push(roomId);
|
||||
});
|
||||
|
||||
await client.start();
|
||||
|
||||
matrixJsClient.emit("Room", {
|
||||
roomId: "!invite:example.org",
|
||||
getMyMembership: () => "invite",
|
||||
});
|
||||
|
||||
expect(invites).toEqual(["!invite:example.org"]);
|
||||
});
|
||||
|
||||
it("replays outstanding invite rooms at startup", async () => {
|
||||
matrixJsClient.getRooms = vi.fn(() => [
|
||||
{
|
||||
roomId: "!pending:example.org",
|
||||
getMyMembership: () => "invite",
|
||||
},
|
||||
{
|
||||
roomId: "!joined:example.org",
|
||||
getMyMembership: () => "join",
|
||||
},
|
||||
]);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
const invites: string[] = [];
|
||||
client.on("room.invite", (roomId) => {
|
||||
invites.push(roomId);
|
||||
});
|
||||
|
||||
await client.start();
|
||||
|
||||
expect(invites).toEqual(["!pending:example.org"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MatrixClient crypto bootstrapping", () => {
|
||||
beforeEach(() => {
|
||||
matrixJsClient = createMatrixJsClientStub();
|
||||
lastCreateClientOpts = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("passes cryptoDatabasePrefix into initRustCrypto", async () => {
|
||||
matrixJsClient.getCrypto = vi.fn(() => undefined);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
encryption: true,
|
||||
cryptoDatabasePrefix: "openclaw-matrix-test",
|
||||
});
|
||||
|
||||
await client.start();
|
||||
|
||||
expect(matrixJsClient.initRustCrypto).toHaveBeenCalledWith({
|
||||
cryptoDatabasePrefix: "openclaw-matrix-test",
|
||||
});
|
||||
});
|
||||
|
||||
it("bootstraps cross-signing with setupNewCrossSigning enabled", async () => {
|
||||
const bootstrapCrossSigning = vi.fn(async () => {});
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning,
|
||||
bootstrapSecretStorage: vi.fn(async () => {}),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
encryption: true,
|
||||
});
|
||||
|
||||
await client.start();
|
||||
|
||||
expect(bootstrapCrossSigning).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authUploadDeviceSigningKeys: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("provides secret storage callbacks and resolves stored recovery key", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-test-"));
|
||||
const recoveryKeyPath = path.join(tmpDir, "recovery-key.json");
|
||||
const privateKeyBase64 = Buffer.from([1, 2, 3, 4]).toString("base64");
|
||||
fs.writeFileSync(
|
||||
recoveryKeyPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
keyId: "SSSSKEY",
|
||||
privateKeyBase64,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
encryption: true,
|
||||
recoveryKeyPath,
|
||||
});
|
||||
|
||||
const callbacks = (lastCreateClientOpts?.cryptoCallbacks ?? null) as {
|
||||
getSecretStorageKey?: (
|
||||
params: { keys: Record<string, unknown> },
|
||||
name: string,
|
||||
) => Promise<[string, Uint8Array] | null>;
|
||||
} | null;
|
||||
expect(callbacks?.getSecretStorageKey).toBeTypeOf("function");
|
||||
|
||||
const resolved = await callbacks?.getSecretStorageKey?.(
|
||||
{ keys: { SSSSKEY: { algorithm: "m.secret_storage.v1.aes-hmac-sha2" } } },
|
||||
"m.cross_signing.master",
|
||||
);
|
||||
expect(resolved?.[0]).toBe("SSSSKEY");
|
||||
expect(Array.from(resolved?.[1] ?? [])).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("schedules periodic crypto snapshot persistence with fake timers", async () => {
|
||||
vi.useFakeTimers();
|
||||
const databasesSpy = vi.spyOn(indexedDB, "databases").mockResolvedValue([]);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
encryption: true,
|
||||
idbSnapshotPath: path.join(os.tmpdir(), "matrix-idb-interval.json"),
|
||||
cryptoDatabasePrefix: "openclaw-matrix-interval",
|
||||
});
|
||||
|
||||
await client.start();
|
||||
const callsAfterStart = databasesSpy.mock.calls.length;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(databasesSpy.mock.calls.length).toBeGreaterThan(callsAfterStart);
|
||||
|
||||
client.stop();
|
||||
const callsAfterStop = databasesSpy.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect(databasesSpy.mock.calls.length).toBe(callsAfterStop);
|
||||
});
|
||||
});
|
||||
@@ -1,527 +0,0 @@
|
||||
// Polyfill IndexedDB for WASM crypto in Node.js
|
||||
import "fake-indexeddb/auto";
|
||||
import {
|
||||
ClientEvent,
|
||||
createClient as createMatrixJsClient,
|
||||
type MatrixClient as MatrixJsClient,
|
||||
type MatrixEvent,
|
||||
} from "matrix-js-sdk";
|
||||
import { VerificationMethod } from "matrix-js-sdk/lib/types.js";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type {
|
||||
MatrixClientEventMap,
|
||||
MatrixCryptoBootstrapApi,
|
||||
MatrixRawEvent,
|
||||
MessageEventContent,
|
||||
} from "./sdk/types.js";
|
||||
import { MatrixCryptoBootstrapper } from "./sdk/crypto-bootstrap.js";
|
||||
import { createMatrixCryptoFacade, type MatrixCryptoFacade } from "./sdk/crypto-facade.js";
|
||||
import { MatrixDecryptBridge } from "./sdk/decrypt-bridge.js";
|
||||
import { matrixEventToRaw, parseMxc } from "./sdk/event-helpers.js";
|
||||
import { MatrixAuthedHttpClient } from "./sdk/http-client.js";
|
||||
import { persistIdbToDisk, restoreIdbFromDisk } from "./sdk/idb-persistence.js";
|
||||
import { ConsoleLogger, LogService, noop } from "./sdk/logger.js";
|
||||
import { MatrixRecoveryKeyStore } from "./sdk/recovery-key-store.js";
|
||||
import { type HttpMethod, type QueryParams } from "./sdk/transport.js";
|
||||
import { MatrixVerificationManager } from "./sdk/verification-manager.js";
|
||||
|
||||
export { ConsoleLogger, LogService };
|
||||
export type {
|
||||
DimensionalFileInfo,
|
||||
FileWithThumbnailInfo,
|
||||
TimedFileInfo,
|
||||
VideoFileInfo,
|
||||
} from "./sdk/types.js";
|
||||
export type {
|
||||
EncryptedFile,
|
||||
LocationMessageEventContent,
|
||||
MessageEventContent,
|
||||
TextualMessageEventContent,
|
||||
} from "./sdk/types.js";
|
||||
|
||||
export class MatrixClient {
|
||||
private readonly client: MatrixJsClient;
|
||||
private readonly emitter = new EventEmitter();
|
||||
private readonly httpClient: MatrixAuthedHttpClient;
|
||||
private readonly localTimeoutMs: number;
|
||||
private readonly initialSyncLimit?: number;
|
||||
private readonly encryptionEnabled: boolean;
|
||||
private readonly idbSnapshotPath?: string;
|
||||
private readonly cryptoDatabasePrefix?: string;
|
||||
private bridgeRegistered = false;
|
||||
private started = false;
|
||||
private selfUserId: string | null;
|
||||
private readonly dmRoomIds = new Set<string>();
|
||||
private cryptoInitialized = false;
|
||||
private readonly decryptBridge: MatrixDecryptBridge<MatrixRawEvent>;
|
||||
private readonly verificationManager = new MatrixVerificationManager();
|
||||
private readonly recoveryKeyStore: MatrixRecoveryKeyStore;
|
||||
private readonly cryptoBootstrapper: MatrixCryptoBootstrapper<MatrixRawEvent>;
|
||||
|
||||
readonly dms = {
|
||||
update: async (): Promise<void> => {
|
||||
await this.refreshDmCache();
|
||||
},
|
||||
isDm: (roomId: string): boolean => this.dmRoomIds.has(roomId),
|
||||
};
|
||||
|
||||
crypto?: MatrixCryptoFacade;
|
||||
|
||||
constructor(
|
||||
homeserver: string,
|
||||
accessToken: string,
|
||||
_storage?: unknown,
|
||||
_cryptoStorage?: unknown,
|
||||
opts: {
|
||||
userId?: string;
|
||||
password?: string;
|
||||
deviceId?: string;
|
||||
localTimeoutMs?: number;
|
||||
encryption?: boolean;
|
||||
initialSyncLimit?: number;
|
||||
recoveryKeyPath?: string;
|
||||
idbSnapshotPath?: string;
|
||||
cryptoDatabasePrefix?: string;
|
||||
} = {},
|
||||
) {
|
||||
this.httpClient = new MatrixAuthedHttpClient(homeserver, accessToken);
|
||||
this.localTimeoutMs = Math.max(1, opts.localTimeoutMs ?? 60_000);
|
||||
this.initialSyncLimit = opts.initialSyncLimit;
|
||||
this.encryptionEnabled = opts.encryption === true;
|
||||
this.idbSnapshotPath = opts.idbSnapshotPath;
|
||||
this.cryptoDatabasePrefix = opts.cryptoDatabasePrefix;
|
||||
this.selfUserId = opts.userId?.trim() || null;
|
||||
this.recoveryKeyStore = new MatrixRecoveryKeyStore(opts.recoveryKeyPath);
|
||||
const cryptoCallbacks = this.encryptionEnabled
|
||||
? this.recoveryKeyStore.buildCryptoCallbacks()
|
||||
: undefined;
|
||||
this.client = createMatrixJsClient({
|
||||
baseUrl: homeserver,
|
||||
accessToken,
|
||||
userId: opts.userId,
|
||||
deviceId: opts.deviceId,
|
||||
localTimeoutMs: this.localTimeoutMs,
|
||||
cryptoCallbacks,
|
||||
verificationMethods: [
|
||||
VerificationMethod.Sas,
|
||||
VerificationMethod.ShowQrCode,
|
||||
VerificationMethod.ScanQrCode,
|
||||
VerificationMethod.Reciprocate,
|
||||
],
|
||||
});
|
||||
this.decryptBridge = new MatrixDecryptBridge<MatrixRawEvent>({
|
||||
client: this.client,
|
||||
toRaw: (event) => matrixEventToRaw(event),
|
||||
emitDecryptedEvent: (roomId, event) => {
|
||||
this.emitter.emit("room.decrypted_event", roomId, event);
|
||||
},
|
||||
emitMessage: (roomId, event) => {
|
||||
this.emitter.emit("room.message", roomId, event);
|
||||
},
|
||||
emitFailedDecryption: (roomId, event, error) => {
|
||||
this.emitter.emit("room.failed_decryption", roomId, event, error);
|
||||
},
|
||||
});
|
||||
this.cryptoBootstrapper = new MatrixCryptoBootstrapper<MatrixRawEvent>({
|
||||
getUserId: () => this.getUserId(),
|
||||
getPassword: () => opts.password,
|
||||
getDeviceId: () => this.client.getDeviceId(),
|
||||
verificationManager: this.verificationManager,
|
||||
recoveryKeyStore: this.recoveryKeyStore,
|
||||
decryptBridge: this.decryptBridge,
|
||||
});
|
||||
|
||||
if (this.encryptionEnabled) {
|
||||
this.crypto = createMatrixCryptoFacade({
|
||||
client: this.client,
|
||||
verificationManager: this.verificationManager,
|
||||
recoveryKeyStore: this.recoveryKeyStore,
|
||||
getRoomStateEvent: (roomId, eventType, stateKey = "") =>
|
||||
this.getRoomStateEvent(roomId, eventType, stateKey),
|
||||
downloadContent: (mxcUrl) => this.downloadContent(mxcUrl),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
on<TEvent extends keyof MatrixClientEventMap>(
|
||||
eventName: TEvent,
|
||||
listener: (...args: MatrixClientEventMap[TEvent]) => void,
|
||||
): this;
|
||||
on(eventName: string, listener: (...args: unknown[]) => void): this;
|
||||
on(eventName: string, listener: (...args: unknown[]) => void): this {
|
||||
this.emitter.on(eventName, listener as (...args: unknown[]) => void);
|
||||
return this;
|
||||
}
|
||||
|
||||
off<TEvent extends keyof MatrixClientEventMap>(
|
||||
eventName: TEvent,
|
||||
listener: (...args: MatrixClientEventMap[TEvent]) => void,
|
||||
): this;
|
||||
off(eventName: string, listener: (...args: unknown[]) => void): this;
|
||||
off(eventName: string, listener: (...args: unknown[]) => void): this {
|
||||
this.emitter.off(eventName, listener as (...args: unknown[]) => void);
|
||||
return this;
|
||||
}
|
||||
|
||||
private idbPersistTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.started) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.registerBridge();
|
||||
await this.initializeCryptoIfNeeded();
|
||||
|
||||
await this.client.startClient({
|
||||
initialSyncLimit: this.initialSyncLimit,
|
||||
});
|
||||
this.started = true;
|
||||
this.emitOutstandingInviteEvents();
|
||||
await this.refreshDmCache().catch(noop);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.idbPersistTimer) {
|
||||
clearInterval(this.idbPersistTimer);
|
||||
this.idbPersistTimer = null;
|
||||
}
|
||||
this.decryptBridge.stop();
|
||||
// Final persist on shutdown
|
||||
persistIdbToDisk({
|
||||
snapshotPath: this.idbSnapshotPath,
|
||||
databasePrefix: this.cryptoDatabasePrefix,
|
||||
}).catch(noop);
|
||||
this.client.stopClient();
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
private async initializeCryptoIfNeeded(): Promise<void> {
|
||||
if (!this.encryptionEnabled || this.cryptoInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore persisted IndexedDB crypto store before initializing WASM crypto.
|
||||
await restoreIdbFromDisk(this.idbSnapshotPath);
|
||||
|
||||
try {
|
||||
await this.client.initRustCrypto({
|
||||
cryptoDatabasePrefix: this.cryptoDatabasePrefix,
|
||||
});
|
||||
this.cryptoInitialized = true;
|
||||
|
||||
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
|
||||
if (crypto) {
|
||||
await this.cryptoBootstrapper.bootstrap(crypto);
|
||||
}
|
||||
|
||||
// Persist the crypto store after successful init (captures fresh keys on first run).
|
||||
await persistIdbToDisk({
|
||||
snapshotPath: this.idbSnapshotPath,
|
||||
databasePrefix: this.cryptoDatabasePrefix,
|
||||
});
|
||||
|
||||
// Periodically persist to capture new Olm sessions and room keys.
|
||||
this.idbPersistTimer = setInterval(() => {
|
||||
persistIdbToDisk({
|
||||
snapshotPath: this.idbSnapshotPath,
|
||||
databasePrefix: this.cryptoDatabasePrefix,
|
||||
}).catch(noop);
|
||||
}, 60_000);
|
||||
} catch (err) {
|
||||
LogService.warn("MatrixClientLite", "Failed to initialize rust crypto:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async getUserId(): Promise<string> {
|
||||
const fromClient = this.client.getUserId();
|
||||
if (fromClient) {
|
||||
this.selfUserId = fromClient;
|
||||
return fromClient;
|
||||
}
|
||||
if (this.selfUserId) {
|
||||
return this.selfUserId;
|
||||
}
|
||||
const whoami = (await this.doRequest("GET", "/_matrix/client/v3/account/whoami")) as {
|
||||
user_id?: string;
|
||||
};
|
||||
const resolved = whoami.user_id?.trim();
|
||||
if (!resolved) {
|
||||
throw new Error("Matrix whoami did not return user_id");
|
||||
}
|
||||
this.selfUserId = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async getJoinedRooms(): Promise<string[]> {
|
||||
const joined = await this.client.getJoinedRooms();
|
||||
return Array.isArray(joined.joined_rooms) ? joined.joined_rooms : [];
|
||||
}
|
||||
|
||||
async getJoinedRoomMembers(roomId: string): Promise<string[]> {
|
||||
const members = await this.client.getJoinedRoomMembers(roomId);
|
||||
const joined = members?.joined;
|
||||
if (!joined || typeof joined !== "object") {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(joined);
|
||||
}
|
||||
|
||||
async getRoomStateEvent(
|
||||
roomId: string,
|
||||
eventType: string,
|
||||
stateKey = "",
|
||||
): Promise<Record<string, unknown>> {
|
||||
const state = await this.client.getStateEvent(roomId, eventType, stateKey);
|
||||
return (state ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async getAccountData(eventType: string): Promise<Record<string, unknown> | undefined> {
|
||||
const event = this.client.getAccountData(eventType);
|
||||
return (event?.getContent() as Record<string, unknown> | undefined) ?? undefined;
|
||||
}
|
||||
|
||||
async setAccountData(eventType: string, content: Record<string, unknown>): Promise<void> {
|
||||
await this.client.setAccountData(eventType as never, content as never);
|
||||
await this.refreshDmCache().catch(noop);
|
||||
}
|
||||
|
||||
async resolveRoom(aliasOrRoomId: string): Promise<string | null> {
|
||||
if (aliasOrRoomId.startsWith("!")) {
|
||||
return aliasOrRoomId;
|
||||
}
|
||||
if (!aliasOrRoomId.startsWith("#")) {
|
||||
return aliasOrRoomId;
|
||||
}
|
||||
try {
|
||||
const resolved = await this.client.getRoomIdForAlias(aliasOrRoomId);
|
||||
return resolved.room_id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(roomId: string, content: MessageEventContent): Promise<string> {
|
||||
const sent = await this.client.sendMessage(roomId, content as never);
|
||||
return sent.event_id;
|
||||
}
|
||||
|
||||
async sendEvent(
|
||||
roomId: string,
|
||||
eventType: string,
|
||||
content: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const sent = await this.client.sendEvent(roomId, eventType as never, content as never);
|
||||
return sent.event_id;
|
||||
}
|
||||
|
||||
async sendStateEvent(
|
||||
roomId: string,
|
||||
eventType: string,
|
||||
stateKey: string,
|
||||
content: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const sent = await this.client.sendStateEvent(
|
||||
roomId,
|
||||
eventType as never,
|
||||
content as never,
|
||||
stateKey,
|
||||
);
|
||||
return sent.event_id;
|
||||
}
|
||||
|
||||
async redactEvent(roomId: string, eventId: string, reason?: string): Promise<string> {
|
||||
const sent = await this.client.redactEvent(
|
||||
roomId,
|
||||
eventId,
|
||||
undefined,
|
||||
reason?.trim() ? { reason } : undefined,
|
||||
);
|
||||
return sent.event_id;
|
||||
}
|
||||
|
||||
async doRequest(
|
||||
method: HttpMethod,
|
||||
endpoint: string,
|
||||
qs?: QueryParams,
|
||||
body?: unknown,
|
||||
opts?: { allowAbsoluteEndpoint?: boolean },
|
||||
): Promise<unknown> {
|
||||
return await this.httpClient.requestJson({
|
||||
method,
|
||||
endpoint,
|
||||
qs,
|
||||
body,
|
||||
timeoutMs: this.localTimeoutMs,
|
||||
allowAbsoluteEndpoint: opts?.allowAbsoluteEndpoint,
|
||||
});
|
||||
}
|
||||
|
||||
async getUserProfile(userId: string): Promise<{ displayname?: string; avatar_url?: string }> {
|
||||
return await this.client.getProfileInfo(userId);
|
||||
}
|
||||
|
||||
async joinRoom(roomId: string): Promise<void> {
|
||||
await this.client.joinRoom(roomId);
|
||||
}
|
||||
|
||||
mxcToHttp(mxcUrl: string): string | null {
|
||||
return this.client.mxcUrlToHttp(mxcUrl, undefined, undefined, undefined, true, false, true);
|
||||
}
|
||||
|
||||
async downloadContent(mxcUrl: string, allowRemote = true): Promise<Buffer> {
|
||||
const parsed = parseMxc(mxcUrl);
|
||||
if (!parsed) {
|
||||
throw new Error(`Invalid Matrix content URI: ${mxcUrl}`);
|
||||
}
|
||||
const endpoint = `/_matrix/media/v3/download/${encodeURIComponent(parsed.server)}/${encodeURIComponent(parsed.mediaId)}`;
|
||||
const response = await this.httpClient.requestRaw({
|
||||
method: "GET",
|
||||
endpoint,
|
||||
qs: { allow_remote: allowRemote },
|
||||
timeoutMs: this.localTimeoutMs,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async uploadContent(file: Buffer, contentType?: string, filename?: string): Promise<string> {
|
||||
const uploaded = await this.client.uploadContent(file, {
|
||||
type: contentType || "application/octet-stream",
|
||||
name: filename,
|
||||
includeFilename: Boolean(filename),
|
||||
});
|
||||
return uploaded.content_uri;
|
||||
}
|
||||
|
||||
async getEvent(roomId: string, eventId: string): Promise<Record<string, unknown>> {
|
||||
return (await this.client.fetchRoomEvent(roomId, eventId)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async setTyping(roomId: string, typing: boolean, timeoutMs: number): Promise<void> {
|
||||
await this.client.sendTyping(roomId, typing, timeoutMs);
|
||||
}
|
||||
|
||||
async sendReadReceipt(roomId: string, eventId: string): Promise<void> {
|
||||
await this.httpClient.requestJson({
|
||||
method: "POST",
|
||||
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/receipt/m.read/${encodeURIComponent(
|
||||
eventId,
|
||||
)}`,
|
||||
body: {},
|
||||
timeoutMs: this.localTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
private registerBridge(): void {
|
||||
if (this.bridgeRegistered) {
|
||||
return;
|
||||
}
|
||||
this.bridgeRegistered = true;
|
||||
|
||||
this.client.on(ClientEvent.Event, (event: MatrixEvent) => {
|
||||
const roomId = event.getRoomId();
|
||||
if (!roomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = matrixEventToRaw(event);
|
||||
const isEncryptedEvent = raw.type === "m.room.encrypted";
|
||||
this.emitter.emit("room.event", roomId, raw);
|
||||
if (isEncryptedEvent) {
|
||||
this.emitter.emit("room.encrypted_event", roomId, raw);
|
||||
} else {
|
||||
if (this.decryptBridge.shouldEmitUnencryptedMessage(roomId, raw.event_id)) {
|
||||
this.emitter.emit("room.message", roomId, raw);
|
||||
}
|
||||
}
|
||||
|
||||
const stateKey = raw.state_key ?? "";
|
||||
const selfUserId = this.client.getUserId() ?? this.selfUserId ?? "";
|
||||
const membership =
|
||||
raw.type === "m.room.member"
|
||||
? (raw.content as { membership?: string }).membership
|
||||
: undefined;
|
||||
if (stateKey && selfUserId && stateKey === selfUserId) {
|
||||
if (membership === "invite") {
|
||||
this.emitter.emit("room.invite", roomId, raw);
|
||||
} else if (membership === "join") {
|
||||
this.emitter.emit("room.join", roomId, raw);
|
||||
}
|
||||
}
|
||||
|
||||
if (isEncryptedEvent) {
|
||||
this.decryptBridge.attachEncryptedEvent(event, roomId);
|
||||
}
|
||||
});
|
||||
|
||||
// Some SDK invite transitions are surfaced as room lifecycle events instead of raw timeline events.
|
||||
this.client.on(ClientEvent.Room, (room) => {
|
||||
this.emitMembershipForRoom(room);
|
||||
});
|
||||
}
|
||||
|
||||
private emitMembershipForRoom(room: unknown): void {
|
||||
const roomObj = room as {
|
||||
roomId?: string;
|
||||
getMyMembership?: () => string | null | undefined;
|
||||
selfMembership?: string | null | undefined;
|
||||
};
|
||||
const roomId = roomObj.roomId?.trim();
|
||||
if (!roomId) {
|
||||
return;
|
||||
}
|
||||
const membership = roomObj.getMyMembership?.() ?? roomObj.selfMembership ?? undefined;
|
||||
const selfUserId = this.client.getUserId() ?? this.selfUserId ?? "";
|
||||
if (!selfUserId) {
|
||||
return;
|
||||
}
|
||||
const raw: MatrixRawEvent = {
|
||||
type: "m.room.member",
|
||||
room_id: roomId,
|
||||
sender: selfUserId,
|
||||
state_key: selfUserId,
|
||||
content: { membership },
|
||||
origin_server_ts: Date.now(),
|
||||
unsigned: { age: 0 },
|
||||
};
|
||||
if (membership === "invite") {
|
||||
this.emitter.emit("room.invite", roomId, raw);
|
||||
return;
|
||||
}
|
||||
if (membership === "join") {
|
||||
this.emitter.emit("room.join", roomId, raw);
|
||||
}
|
||||
}
|
||||
|
||||
private emitOutstandingInviteEvents(): void {
|
||||
const listRooms = (this.client as { getRooms?: () => unknown[] }).getRooms;
|
||||
if (typeof listRooms !== "function") {
|
||||
return;
|
||||
}
|
||||
const rooms = listRooms.call(this.client);
|
||||
if (!Array.isArray(rooms)) {
|
||||
return;
|
||||
}
|
||||
for (const room of rooms) {
|
||||
this.emitMembershipForRoom(room);
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshDmCache(): Promise<void> {
|
||||
const direct = await this.getAccountData("m.direct");
|
||||
this.dmRoomIds.clear();
|
||||
if (!direct || typeof direct !== "object") {
|
||||
return;
|
||||
}
|
||||
for (const value of Object.values(direct)) {
|
||||
if (!Array.isArray(value)) {
|
||||
continue;
|
||||
}
|
||||
for (const roomId of value) {
|
||||
if (typeof roomId === "string" && roomId.trim()) {
|
||||
this.dmRoomIds.add(roomId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user