mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:19:35 +00:00
Matrix-js: sync with main plugin-loading standards
This commit is contained in:
242
extensions/matrix-js/src/actions.ts
Normal file
242
extensions/matrix-js/src/actions.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import {
|
||||
createActionGate,
|
||||
readNumberParam,
|
||||
readStringParam,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelMessageActionContext,
|
||||
type ChannelMessageActionName,
|
||||
type ChannelToolSend,
|
||||
} from "openclaw/plugin-sdk/matrix-js";
|
||||
import { resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
import { handleMatrixAction } from "./tool-actions.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: ({ cfg }) => {
|
||||
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig });
|
||||
if (!account.enabled || !account.configured) {
|
||||
return [];
|
||||
}
|
||||
const gate = createActionGate((cfg as CoreConfig).channels?.["matrix-js"]?.actions);
|
||||
const actions = new Set<ChannelMessageActionName>(["send", "poll"]);
|
||||
if (gate("reactions")) {
|
||||
actions.add("react");
|
||||
actions.add("reactions");
|
||||
}
|
||||
if (gate("messages")) {
|
||||
actions.add("read");
|
||||
actions.add("edit");
|
||||
actions.add("delete");
|
||||
}
|
||||
if (gate("pins")) {
|
||||
actions.add("pin");
|
||||
actions.add("unpin");
|
||||
actions.add("list-pins");
|
||||
}
|
||||
if (gate("memberInfo")) {
|
||||
actions.add("member-info");
|
||||
}
|
||||
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",
|
||||
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;
|
||||
}
|
||||
return { to };
|
||||
},
|
||||
handleAction: async (ctx: ChannelMessageActionContext) => {
|
||||
const { action, params, cfg } = ctx;
|
||||
const resolveRoomId = () =>
|
||||
readStringParam(params, "roomId") ??
|
||||
readStringParam(params, "channelId") ??
|
||||
readStringParam(params, "to", { required: true });
|
||||
|
||||
if (action === "send") {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const content = readStringParam(params, "message", {
|
||||
required: true,
|
||||
allowEmpty: true,
|
||||
});
|
||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
const threadId = readStringParam(params, "threadId");
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to,
|
||||
content,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
replyToId: replyTo ?? undefined,
|
||||
threadId: threadId ?? undefined,
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "react") {
|
||||
const messageId = readStringParam(params, "messageId", { required: true });
|
||||
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
|
||||
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "react",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
emoji,
|
||||
remove,
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "reactions") {
|
||||
const messageId = readStringParam(params, "messageId", { required: true });
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "reactions",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
limit,
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "read") {
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "readMessages",
|
||||
roomId: resolveRoomId(),
|
||||
limit,
|
||||
before: readStringParam(params, "before"),
|
||||
after: readStringParam(params, "after"),
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "edit") {
|
||||
const messageId = readStringParam(params, "messageId", { required: true });
|
||||
const content = readStringParam(params, "message", { required: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "editMessage",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
content,
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "delete") {
|
||||
const messageId = readStringParam(params, "messageId", { required: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "deleteMessage",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "pin" || action === "unpin" || action === "list-pins") {
|
||||
const messageId =
|
||||
action === "list-pins"
|
||||
? undefined
|
||||
: readStringParam(params, "messageId", { required: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action:
|
||||
action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "member-info") {
|
||||
const userId = readStringParam(params, "userId", { required: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "memberInfo",
|
||||
userId,
|
||||
roomId: readStringParam(params, "roomId") ?? readStringParam(params, "channelId"),
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "channel-info") {
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "channelInfo",
|
||||
roomId: resolveRoomId(),
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "permissions") {
|
||||
const operation = (
|
||||
readStringParam(params, "operation") ??
|
||||
readStringParam(params, "mode") ??
|
||||
"verification-list"
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const operationToAction: Record<string, string> = {
|
||||
"encryption-status": "encryptionStatus",
|
||||
"verification-status": "verificationStatus",
|
||||
"verification-bootstrap": "verificationBootstrap",
|
||||
"verification-recovery-key": "verificationRecoveryKey",
|
||||
"verification-backup-status": "verificationBackupStatus",
|
||||
"verification-backup-restore": "verificationBackupRestore",
|
||||
"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-js.`);
|
||||
},
|
||||
};
|
||||
397
extensions/matrix-js/src/channel.directory.test.ts
Normal file
397
extensions/matrix-js/src/channel.directory.test.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix-js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { matrixPlugin } from "./channel.js";
|
||||
import { migrateMatrixLegacyCredentialsToDefaultAccount } from "./config-migration.js";
|
||||
import { setMatrixRuntime } from "./runtime.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
describe("matrix directory", () => {
|
||||
const runtimeEnv: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setMatrixRuntime({
|
||||
state: {
|
||||
resolveStateDir: (_env, homeDir) => (homeDir ?? (() => "/tmp"))(),
|
||||
},
|
||||
} as PluginRuntime);
|
||||
});
|
||||
|
||||
it("lists peers and groups from config", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
dm: { allowFrom: ["matrix:@alice:example.org", "bob"] },
|
||||
groupAllowFrom: ["@dana:example.org"],
|
||||
groups: {
|
||||
"!room1:example.org": { users: ["@carol:example.org"] },
|
||||
"#alias:example.org": { users: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
expect(matrixPlugin.directory).toBeTruthy();
|
||||
expect(matrixPlugin.directory?.listPeers).toBeTruthy();
|
||||
expect(matrixPlugin.directory?.listGroups).toBeTruthy();
|
||||
|
||||
await expect(
|
||||
matrixPlugin.directory!.listPeers!({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "user", id: "user:@alice:example.org" },
|
||||
{ kind: "user", id: "bob", name: "incomplete id; expected @user:server" },
|
||||
{ kind: "user", id: "user:@carol:example.org" },
|
||||
{ kind: "user", id: "user:@dana:example.org" },
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(
|
||||
matrixPlugin.directory!.listGroups!({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "group", id: "room:!room1:example.org" },
|
||||
{ kind: "group", id: "#alias:example.org" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves replyToMode from account config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
replyToMode: "off",
|
||||
accounts: {
|
||||
Assistant: {
|
||||
replyToMode: "all",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
expect(matrixPlugin.threading?.resolveReplyToMode).toBeTruthy();
|
||||
expect(
|
||||
matrixPlugin.threading?.resolveReplyToMode?.({
|
||||
cfg,
|
||||
accountId: "assistant",
|
||||
chatType: "direct",
|
||||
}),
|
||||
).toBe("all");
|
||||
expect(
|
||||
matrixPlugin.threading?.resolveReplyToMode?.({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
chatType: "direct",
|
||||
}),
|
||||
).toBe("off");
|
||||
});
|
||||
|
||||
it("resolves group mention policy from account config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
groups: {
|
||||
"!room:example.org": { requireMention: true },
|
||||
},
|
||||
accounts: {
|
||||
Assistant: {
|
||||
groups: {
|
||||
"!room:example.org": { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
expect(matrixPlugin.groups!.resolveRequireMention!({ cfg, groupId: "!room:example.org" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
matrixPlugin.groups!.resolveRequireMention!({
|
||||
cfg,
|
||||
accountId: "assistant",
|
||||
groupId: "!room:example.org",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("writes matrix-js non-default account credentials under channels.matrix-js.accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
homeserver: "https://default.example.org",
|
||||
accessToken: "default-token",
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
const updated = matrixPlugin.setup!.applyAccountConfig({
|
||||
cfg,
|
||||
accountId: "ops",
|
||||
input: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
}) as CoreConfig;
|
||||
|
||||
expect(updated.channels?.["matrix-js"]?.accessToken).toBeUndefined();
|
||||
expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({
|
||||
accessToken: "default-token",
|
||||
homeserver: "https://default.example.org",
|
||||
});
|
||||
expect(updated.channels?.["matrix-js"]?.accounts?.ops).toMatchObject({
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes default matrix-js account credentials under channels.matrix-js.accounts.default", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
homeserver: "https://legacy.example.org",
|
||||
accessToken: "legacy-token",
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
const updated = matrixPlugin.setup!.applyAccountConfig({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
input: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "bot-token",
|
||||
},
|
||||
}) as CoreConfig;
|
||||
|
||||
expect(updated.channels?.["matrix-js"]?.homeserver).toBeUndefined();
|
||||
expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "bot-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates legacy top-level matrix-js credentials into accounts.default", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
name: "bot-default",
|
||||
homeserver: "https://legacy.example.org",
|
||||
userId: "@legacy:example.org",
|
||||
accessToken: "legacy-token",
|
||||
deviceName: "Legacy Device",
|
||||
encryption: true,
|
||||
groupPolicy: "allowlist",
|
||||
groups: {
|
||||
"!legacy-room:example.org": { allow: true },
|
||||
},
|
||||
register: false,
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
const updated = migrateMatrixLegacyCredentialsToDefaultAccount(cfg);
|
||||
expect(updated.channels?.["matrix-js"]?.homeserver).toBeUndefined();
|
||||
expect(updated.channels?.["matrix-js"]?.accessToken).toBeUndefined();
|
||||
expect(updated.channels?.["matrix-js"]?.deviceName).toBeUndefined();
|
||||
expect(updated.channels?.["matrix-js"]?.encryption).toBeUndefined();
|
||||
expect((updated.channels?.["matrix-js"] as Record<string, unknown>)?.register).toBeUndefined();
|
||||
expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({
|
||||
name: "bot-default",
|
||||
homeserver: "https://legacy.example.org",
|
||||
userId: "@legacy:example.org",
|
||||
accessToken: "legacy-token",
|
||||
deviceName: "Legacy Device",
|
||||
encryption: true,
|
||||
groupPolicy: "allowlist",
|
||||
groups: {
|
||||
"!legacy-room:example.org": { allow: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("merges top-level object defaults into accounts.default during migration", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
dm: {
|
||||
policy: "allowlist",
|
||||
allowFrom: ["@legacy:example.org"],
|
||||
},
|
||||
accounts: {
|
||||
default: {
|
||||
dm: {
|
||||
policy: "pairing",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
const updated = migrateMatrixLegacyCredentialsToDefaultAccount(cfg);
|
||||
expect(updated.channels?.["matrix-js"]?.dm).toBeUndefined();
|
||||
expect(updated.channels?.["matrix-js"]?.accounts?.default?.dm).toMatchObject({
|
||||
policy: "pairing",
|
||||
allowFrom: ["@legacy:example.org"],
|
||||
});
|
||||
});
|
||||
|
||||
it("requires account-scoped env vars when --use-env is set for non-default accounts", () => {
|
||||
const envKeys = [
|
||||
"MATRIX_OPS_HOMESERVER",
|
||||
"MATRIX_OPS_USER_ID",
|
||||
"MATRIX_OPS_ACCESS_TOKEN",
|
||||
"MATRIX_OPS_PASSWORD",
|
||||
] as const;
|
||||
const previousEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])) as Record<
|
||||
(typeof envKeys)[number],
|
||||
string | undefined
|
||||
>;
|
||||
for (const key of envKeys) {
|
||||
delete process.env[key];
|
||||
}
|
||||
try {
|
||||
const error = matrixPlugin.setup!.validateInput?.({
|
||||
cfg: {} as CoreConfig,
|
||||
accountId: "ops",
|
||||
input: { useEnv: true },
|
||||
});
|
||||
expect(error).toBe(
|
||||
'Set per-account env vars for "ops" (for example MATRIX_OPS_HOMESERVER + MATRIX_OPS_ACCESS_TOKEN or MATRIX_OPS_USER_ID + MATRIX_OPS_PASSWORD).',
|
||||
);
|
||||
} finally {
|
||||
for (const key of envKeys) {
|
||||
if (previousEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = previousEnv[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts --use-env for non-default account when scoped env vars are present", () => {
|
||||
const envKeys = {
|
||||
MATRIX_OPS_HOMESERVER: process.env.MATRIX_OPS_HOMESERVER,
|
||||
MATRIX_OPS_ACCESS_TOKEN: process.env.MATRIX_OPS_ACCESS_TOKEN,
|
||||
};
|
||||
process.env.MATRIX_OPS_HOMESERVER = "https://ops.example.org";
|
||||
process.env.MATRIX_OPS_ACCESS_TOKEN = "ops-token";
|
||||
try {
|
||||
const error = matrixPlugin.setup!.validateInput?.({
|
||||
cfg: {} as CoreConfig,
|
||||
accountId: "ops",
|
||||
input: { useEnv: true },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(envKeys)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves account id from input name when explicit account id is missing", () => {
|
||||
const accountId = matrixPlugin.setup!.resolveAccountId?.({
|
||||
cfg: {} as CoreConfig,
|
||||
accountId: undefined,
|
||||
input: { name: "Main Bot" },
|
||||
});
|
||||
expect(accountId).toBe("main-bot");
|
||||
});
|
||||
|
||||
it("resolves binding account id from agent id when omitted", () => {
|
||||
const accountId = matrixPlugin.setup!.resolveBindingAccountId?.({
|
||||
cfg: {} as CoreConfig,
|
||||
agentId: "Ops",
|
||||
accountId: undefined,
|
||||
});
|
||||
expect(accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("clears stale access token when switching an account to password auth", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
accounts: {
|
||||
default: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "old-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
const updated = matrixPlugin.setup!.applyAccountConfig({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
input: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "new-password",
|
||||
},
|
||||
}) as CoreConfig;
|
||||
|
||||
expect(updated.channels?.["matrix-js"]?.accounts?.default?.password).toBe("new-password");
|
||||
expect(updated.channels?.["matrix-js"]?.accounts?.default?.accessToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears stale password when switching an account to token auth", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
accounts: {
|
||||
default: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "old-password",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
const updated = matrixPlugin.setup!.applyAccountConfig({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
input: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "new-token",
|
||||
},
|
||||
}) as CoreConfig;
|
||||
|
||||
expect(updated.channels?.["matrix-js"]?.accounts?.default?.accessToken).toBe("new-token");
|
||||
expect(updated.channels?.["matrix-js"]?.accounts?.default?.password).toBeUndefined();
|
||||
});
|
||||
});
|
||||
490
extensions/matrix-js/src/channel.ts
Normal file
490
extensions/matrix-js/src/channel.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelSetupInput,
|
||||
type ChannelPlugin,
|
||||
} from "openclaw/plugin-sdk/matrix-js";
|
||||
import { matrixMessageActions } from "./actions.js";
|
||||
import { migrateMatrixLegacyCredentialsToDefaultAccount } from "./config-migration.js";
|
||||
import { MatrixConfigSchema } from "./config-schema.js";
|
||||
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
|
||||
import {
|
||||
resolveMatrixGroupRequireMention,
|
||||
resolveMatrixGroupToolPolicy,
|
||||
} from "./group-mentions.js";
|
||||
import {
|
||||
listMatrixAccountIds,
|
||||
resolveMatrixAccountConfig,
|
||||
resolveDefaultMatrixAccountId,
|
||||
resolveMatrixAccount,
|
||||
type ResolvedMatrixAccount,
|
||||
} from "./matrix/accounts.js";
|
||||
import {
|
||||
getMatrixScopedEnvVarNames,
|
||||
hasReadyMatrixEnvAuth,
|
||||
resolveMatrixAuth,
|
||||
resolveScopedMatrixEnvConfig,
|
||||
} from "./matrix/client.js";
|
||||
import { updateMatrixAccountConfig } from "./matrix/config-update.js";
|
||||
import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
|
||||
import { probeMatrix } from "./matrix/probe.js";
|
||||
import { isSupportedMatrixAvatarSource } from "./matrix/profile.js";
|
||||
import { sendMessageMatrix } from "./matrix/send.js";
|
||||
import { matrixOnboardingAdapter } from "./onboarding.js";
|
||||
import { matrixOutbound } from "./outbound.js";
|
||||
import { resolveMatrixTargets } from "./resolve-targets.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
|
||||
let matrixStartupLock: Promise<void> = Promise.resolve();
|
||||
|
||||
const meta = {
|
||||
id: "matrix-js",
|
||||
label: "Matrix-js",
|
||||
selectionLabel: "Matrix-js (plugin)",
|
||||
docsPath: "/channels/matrix-js",
|
||||
docsLabel: "matrix-js",
|
||||
blurb: "open protocol; configure a homeserver + access token.",
|
||||
order: 70,
|
||||
quickstartAllowFrom: true,
|
||||
};
|
||||
|
||||
function normalizeMatrixMessagingTarget(raw: string): string | undefined {
|
||||
let normalized = raw.trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (lowered.startsWith("matrix:")) {
|
||||
normalized = normalized.slice("matrix:".length).trim();
|
||||
}
|
||||
const stripped = normalized.replace(/^(room|channel|user):/i, "").trim();
|
||||
return stripped || undefined;
|
||||
}
|
||||
|
||||
function resolveAvatarInput(input: ChannelSetupInput): string | undefined {
|
||||
const avatarUrl = (input as ChannelSetupInput & { avatarUrl?: string }).avatarUrl;
|
||||
const trimmed = avatarUrl?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
id: "matrix-js",
|
||||
meta,
|
||||
onboarding: matrixOnboardingAdapter,
|
||||
pairing: {
|
||||
idLabel: "matrixUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^matrix:/i, ""),
|
||||
notifyApproval: async ({ id }) => {
|
||||
await sendMessageMatrix(`user:${id}`, PAIRING_APPROVED_MESSAGE);
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group", "thread"],
|
||||
polls: true,
|
||||
reactions: true,
|
||||
threads: true,
|
||||
media: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.matrix-js"] },
|
||||
configSchema: buildChannelConfigSchema(MatrixConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listMatrixAccountIds(cfg as CoreConfig),
|
||||
resolveAccount: (cfg, accountId) => resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultMatrixAccountId(cfg as CoreConfig),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
sectionKey: "matrix-js",
|
||||
accountId,
|
||||
enabled,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
sectionKey: "matrix-js",
|
||||
accountId,
|
||||
clearBaseFields: [
|
||||
"name",
|
||||
"homeserver",
|
||||
"userId",
|
||||
"accessToken",
|
||||
"password",
|
||||
"deviceName",
|
||||
"avatarUrl",
|
||||
"initialSyncLimit",
|
||||
],
|
||||
}),
|
||||
isConfigured: (account) => account.configured,
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
baseUrl: account.homeserver,
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg, accountId }) => {
|
||||
const matrixConfig = resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId });
|
||||
return (matrixConfig.dm?.allowFrom ?? []).map((entry: string | number) => String(entry));
|
||||
},
|
||||
formatAllowFrom: ({ allowFrom }) => normalizeMatrixAllowList(allowFrom),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ account }) => {
|
||||
const accountId = account.accountId;
|
||||
const prefix =
|
||||
accountId && accountId !== "default"
|
||||
? `channels.matrix-js.accounts.${accountId}.dm`
|
||||
: "channels.matrix-js.dm";
|
||||
return {
|
||||
policy: account.config.dm?.policy ?? "pairing",
|
||||
allowFrom: account.config.dm?.allowFrom ?? [],
|
||||
policyPath: `${prefix}.policy`,
|
||||
allowFromPath: `${prefix}.allowFrom`,
|
||||
approveHint: formatPairingApproveHint("matrix-js"),
|
||||
normalizeEntry: (raw) => normalizeMatrixUserId(raw),
|
||||
};
|
||||
},
|
||||
collectWarnings: ({ account, cfg }) => {
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg as CoreConfig);
|
||||
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: (cfg as CoreConfig).channels?.["matrix-js"] !== undefined,
|
||||
groupPolicy: account.config.groupPolicy,
|
||||
defaultGroupPolicy,
|
||||
});
|
||||
if (groupPolicy !== "open") {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
'- Matrix rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set channels.matrix-js.groupPolicy="allowlist" + channels.matrix-js.groups (and optionally channels.matrix-js.groupAllowFrom) to restrict rooms.',
|
||||
];
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveMatrixGroupRequireMention,
|
||||
resolveToolPolicy: resolveMatrixGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg, accountId }) =>
|
||||
resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }).replyToMode ?? "off",
|
||||
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||
const currentTarget = context.To;
|
||||
return {
|
||||
currentChannelId: currentTarget?.trim() || undefined,
|
||||
currentThreadTs:
|
||||
context.MessageThreadId != null ? String(context.MessageThreadId) : context.ReplyToId,
|
||||
hasRepliedRef,
|
||||
};
|
||||
},
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeMatrixMessagingTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (/^(matrix:)?[!#@]/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
return trimmed.includes(":");
|
||||
},
|
||||
hint: "<room|alias|user>",
|
||||
},
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const entry of account.config.dm?.allowFrom ?? []) {
|
||||
const raw = String(entry).trim();
|
||||
if (!raw || raw === "*") {
|
||||
continue;
|
||||
}
|
||||
ids.add(raw.replace(/^matrix:/i, ""));
|
||||
}
|
||||
|
||||
for (const entry of account.config.groupAllowFrom ?? []) {
|
||||
const raw = String(entry).trim();
|
||||
if (!raw || raw === "*") {
|
||||
continue;
|
||||
}
|
||||
ids.add(raw.replace(/^matrix:/i, ""));
|
||||
}
|
||||
|
||||
const groups = account.config.groups ?? account.config.rooms ?? {};
|
||||
for (const room of Object.values(groups)) {
|
||||
for (const entry of room.users ?? []) {
|
||||
const raw = String(entry).trim();
|
||||
if (!raw || raw === "*") {
|
||||
continue;
|
||||
}
|
||||
ids.add(raw.replace(/^matrix:/i, ""));
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ids)
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.map((raw) => {
|
||||
const lowered = raw.toLowerCase();
|
||||
const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
|
||||
if (cleaned.startsWith("@")) {
|
||||
return `user:${cleaned}`;
|
||||
}
|
||||
return cleaned;
|
||||
})
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => {
|
||||
const raw = id.startsWith("user:") ? id.slice("user:".length) : id;
|
||||
const incomplete = !raw.startsWith("@") || !raw.includes(":");
|
||||
return {
|
||||
kind: "user",
|
||||
id,
|
||||
...(incomplete ? { name: "incomplete id; expected @user:server" } : {}),
|
||||
};
|
||||
});
|
||||
},
|
||||
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const groups = account.config.groups ?? account.config.rooms ?? {};
|
||||
const ids = Object.keys(groups)
|
||||
.map((raw) => raw.trim())
|
||||
.filter((raw) => Boolean(raw) && raw !== "*")
|
||||
.map((raw) => raw.replace(/^matrix:/i, ""))
|
||||
.map((raw) => {
|
||||
const lowered = raw.toLowerCase();
|
||||
if (lowered.startsWith("room:") || lowered.startsWith("channel:")) {
|
||||
return raw;
|
||||
}
|
||||
if (raw.startsWith("!")) {
|
||||
return `room:${raw}`;
|
||||
}
|
||||
return raw;
|
||||
})
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "group", id }) as const);
|
||||
return ids;
|
||||
},
|
||||
listPeersLive: async ({ cfg, accountId, query, limit }) =>
|
||||
listMatrixDirectoryPeersLive({ cfg, accountId, query, limit }),
|
||||
listGroupsLive: async ({ cfg, accountId, query, limit }) =>
|
||||
listMatrixDirectoryGroupsLive({ cfg, accountId, query, limit }),
|
||||
},
|
||||
resolver: {
|
||||
resolveTargets: async ({ cfg, inputs, kind, runtime }) =>
|
||||
resolveMatrixTargets({ cfg, inputs, kind, runtime }),
|
||||
},
|
||||
actions: matrixMessageActions,
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId, input }) =>
|
||||
normalizeAccountId(accountId?.trim() || input?.name?.trim()),
|
||||
resolveBindingAccountId: ({ agentId, accountId }) =>
|
||||
normalizeAccountId(accountId?.trim() || agentId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
channelKey: "matrix-js",
|
||||
accountId,
|
||||
name,
|
||||
alwaysUseAccounts: true,
|
||||
}),
|
||||
validateInput: ({ accountId, input }) => {
|
||||
const avatarUrl = resolveAvatarInput(input);
|
||||
if (avatarUrl && !isSupportedMatrixAvatarSource(avatarUrl)) {
|
||||
return "Matrix avatar URL must be an mxc:// URI or an http(s) URL";
|
||||
}
|
||||
if (input.useEnv) {
|
||||
const scopedEnv = resolveScopedMatrixEnvConfig(accountId, process.env);
|
||||
const scopedReady = hasReadyMatrixEnvAuth(scopedEnv);
|
||||
if (accountId !== DEFAULT_ACCOUNT_ID && !scopedReady) {
|
||||
const keys = getMatrixScopedEnvVarNames(accountId);
|
||||
return `Set per-account env vars for "${accountId}" (for example ${keys.homeserver} + ${keys.accessToken} or ${keys.userId} + ${keys.password}).`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!input.homeserver?.trim()) {
|
||||
return "Matrix requires --homeserver";
|
||||
}
|
||||
const accessToken = input.accessToken?.trim();
|
||||
const password = input.password?.trim();
|
||||
const userId = input.userId?.trim();
|
||||
if (!accessToken && !password) {
|
||||
return "Matrix requires --access-token or --password";
|
||||
}
|
||||
if (!accessToken) {
|
||||
if (!userId) {
|
||||
return "Matrix requires --user-id when using --password";
|
||||
}
|
||||
if (!password) {
|
||||
return "Matrix requires --password when using --user-id";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const migratedConfig = migrateMatrixLegacyCredentialsToDefaultAccount(cfg as CoreConfig);
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg: migratedConfig,
|
||||
channelKey: "matrix-js",
|
||||
accountId,
|
||||
name: input.name,
|
||||
alwaysUseAccounts: true,
|
||||
});
|
||||
const next = migrateBaseNameToDefaultAccount({
|
||||
cfg: namedConfig,
|
||||
channelKey: "matrix-js",
|
||||
});
|
||||
if (input.useEnv) {
|
||||
return setAccountEnabledInConfigSection({
|
||||
cfg: next as CoreConfig,
|
||||
sectionKey: "matrix-js",
|
||||
accountId,
|
||||
enabled: true,
|
||||
}) as CoreConfig;
|
||||
}
|
||||
const accessToken = input.accessToken?.trim();
|
||||
const password = input.password?.trim();
|
||||
const userId = input.userId?.trim();
|
||||
return updateMatrixAccountConfig(next as CoreConfig, accountId, {
|
||||
homeserver: input.homeserver?.trim(),
|
||||
userId: password && !userId ? null : userId,
|
||||
accessToken: accessToken || (password ? null : undefined),
|
||||
password: password || (accessToken ? null : undefined),
|
||||
deviceName: input.deviceName?.trim(),
|
||||
avatarUrl: resolveAvatarInput(input),
|
||||
initialSyncLimit: input.initialSyncLimit,
|
||||
});
|
||||
},
|
||||
},
|
||||
outbound: matrixOutbound,
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
collectStatusIssues: (accounts) =>
|
||||
accounts.flatMap((account) => {
|
||||
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
||||
if (!lastError) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
channel: "matrix-js",
|
||||
accountId: account.accountId,
|
||||
kind: "runtime",
|
||||
message: `Channel error: ${lastError}`,
|
||||
},
|
||||
];
|
||||
}),
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
baseUrl: snapshot.baseUrl ?? null,
|
||||
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 ({ account, timeoutMs, cfg }) => {
|
||||
try {
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
return await probeMatrix({
|
||||
homeserver: auth.homeserver,
|
||||
accessToken: auth.accessToken,
|
||||
userId: auth.userId,
|
||||
timeoutMs,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
elapsedMs: 0,
|
||||
};
|
||||
}
|
||||
},
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
baseUrl: account.homeserver,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
probe,
|
||||
lastProbeAt: runtime?.lastProbeAt ?? null,
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
}),
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
baseUrl: account.homeserver,
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting provider (${account.homeserver ?? "matrix"})`);
|
||||
|
||||
// Serialize startup: wait for any previous startup to complete import phase.
|
||||
// This works around a race condition with concurrent dynamic imports.
|
||||
//
|
||||
// INVARIANT: The import() below cannot hang because:
|
||||
// 1. It only loads local ESM modules with no circular awaits
|
||||
// 2. Module initialization is synchronous (no top-level await in ./matrix/index.js)
|
||||
// 3. The lock only serializes the import phase, not the provider startup
|
||||
const previousLock = matrixStartupLock;
|
||||
let releaseLock: () => void = () => {};
|
||||
matrixStartupLock = new Promise<void>((resolve) => {
|
||||
releaseLock = resolve;
|
||||
});
|
||||
await previousLock;
|
||||
|
||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||
// Wrap in try/finally to ensure lock is released even if import fails.
|
||||
let monitorMatrixProvider: typeof import("./matrix/index.js").monitorMatrixProvider;
|
||||
try {
|
||||
const module = await import("./matrix/index.js");
|
||||
monitorMatrixProvider = module.monitorMatrixProvider;
|
||||
} finally {
|
||||
// Release lock after import completes or fails
|
||||
releaseLock();
|
||||
}
|
||||
|
||||
return monitorMatrixProvider({
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
mediaMaxMb: account.config.mediaMaxMb,
|
||||
initialSyncLimit: account.config.initialSyncLimit,
|
||||
replyToMode: account.config.replyToMode,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
527
extensions/matrix-js/src/cli.test.ts
Normal file
527
extensions/matrix-js/src/cli.test.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
import { Command } from "commander";
|
||||
import { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix-js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const bootstrapMatrixVerificationMock = vi.fn();
|
||||
const getMatrixRoomKeyBackupStatusMock = vi.fn();
|
||||
const getMatrixVerificationStatusMock = vi.fn();
|
||||
const matrixSetupApplyAccountConfigMock = vi.fn();
|
||||
const matrixSetupValidateInputMock = vi.fn();
|
||||
const matrixRuntimeLoadConfigMock = vi.fn();
|
||||
const matrixRuntimeWriteConfigFileMock = vi.fn();
|
||||
const restoreMatrixRoomKeyBackupMock = vi.fn();
|
||||
const setMatrixSdkLogModeMock = vi.fn();
|
||||
const updateMatrixOwnProfileMock = vi.fn();
|
||||
const verifyMatrixRecoveryKeyMock = vi.fn();
|
||||
|
||||
vi.mock("./matrix/actions/verification.js", () => ({
|
||||
bootstrapMatrixVerification: (...args: unknown[]) => bootstrapMatrixVerificationMock(...args),
|
||||
getMatrixRoomKeyBackupStatus: (...args: unknown[]) => getMatrixRoomKeyBackupStatusMock(...args),
|
||||
getMatrixVerificationStatus: (...args: unknown[]) => getMatrixVerificationStatusMock(...args),
|
||||
restoreMatrixRoomKeyBackup: (...args: unknown[]) => restoreMatrixRoomKeyBackupMock(...args),
|
||||
verifyMatrixRecoveryKey: (...args: unknown[]) => verifyMatrixRecoveryKeyMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./matrix/client/logging.js", () => ({
|
||||
setMatrixSdkLogMode: (...args: unknown[]) => setMatrixSdkLogModeMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./matrix/actions/profile.js", () => ({
|
||||
updateMatrixOwnProfile: (...args: unknown[]) => updateMatrixOwnProfileMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./channel.js", () => ({
|
||||
matrixPlugin: {
|
||||
setup: {
|
||||
applyAccountConfig: (...args: unknown[]) => matrixSetupApplyAccountConfigMock(...args),
|
||||
validateInput: (...args: unknown[]) => matrixSetupValidateInputMock(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getMatrixRuntime: () => ({
|
||||
config: {
|
||||
loadConfig: (...args: unknown[]) => matrixRuntimeLoadConfigMock(...args),
|
||||
writeConfigFile: (...args: unknown[]) => matrixRuntimeWriteConfigFileMock(...args),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
let registerMatrixJsCli: typeof import("./cli.js").registerMatrixJsCli;
|
||||
|
||||
function buildProgram(): Command {
|
||||
const program = new Command();
|
||||
registerMatrixJsCli({ program });
|
||||
return program;
|
||||
}
|
||||
|
||||
function formatExpectedLocalTimestamp(value: string): string {
|
||||
return formatZonedTimestamp(new Date(value), { displaySeconds: true }) ?? value;
|
||||
}
|
||||
|
||||
describe("matrix-js CLI verification commands", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
process.exitCode = undefined;
|
||||
({ registerMatrixJsCli } = await import("./cli.js"));
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
matrixSetupValidateInputMock.mockReturnValue(null);
|
||||
matrixSetupApplyAccountConfigMock.mockImplementation(({ cfg }: { cfg: unknown }) => cfg);
|
||||
matrixRuntimeLoadConfigMock.mockReturnValue({});
|
||||
matrixRuntimeWriteConfigFileMock.mockResolvedValue(undefined);
|
||||
updateMatrixOwnProfileMock.mockResolvedValue({
|
||||
skipped: false,
|
||||
displayNameUpdated: true,
|
||||
avatarUpdated: false,
|
||||
resolvedAvatarUrl: null,
|
||||
convertedAvatarFromHttp: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
it("sets non-zero exit code for device verification failures in JSON mode", async () => {
|
||||
verifyMatrixRecoveryKeyMock.mockResolvedValue({
|
||||
success: false,
|
||||
error: "invalid key",
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix-js", "verify", "device", "bad-key", "--json"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("sets non-zero exit code for bootstrap failures in JSON mode", async () => {
|
||||
bootstrapMatrixVerificationMock.mockResolvedValue({
|
||||
success: false,
|
||||
error: "bootstrap failed",
|
||||
verification: {},
|
||||
crossSigning: {},
|
||||
pendingVerifications: 0,
|
||||
cryptoBootstrap: null,
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix-js", "verify", "bootstrap", "--json"], { from: "user" });
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("sets non-zero exit code for backup restore failures in JSON mode", async () => {
|
||||
restoreMatrixRoomKeyBackupMock.mockResolvedValue({
|
||||
success: false,
|
||||
error: "missing backup key",
|
||||
backupVersion: null,
|
||||
imported: 0,
|
||||
total: 0,
|
||||
loadedFromSecretStorage: false,
|
||||
backup: {
|
||||
serverVersion: "1",
|
||||
activeVersion: null,
|
||||
trusted: true,
|
||||
matchesDecryptionKey: false,
|
||||
decryptionKeyCached: false,
|
||||
},
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix-js", "verify", "backup", "restore", "--json"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("adds a matrix-js account and prints a binding hint", async () => {
|
||||
matrixRuntimeLoadConfigMock.mockReturnValue({ channels: {} });
|
||||
matrixSetupApplyAccountConfigMock.mockImplementation(
|
||||
({ cfg, accountId }: { cfg: Record<string, unknown>; accountId: string }) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...(cfg.channels as Record<string, unknown> | undefined),
|
||||
"matrix-js": {
|
||||
accounts: {
|
||||
[accountId]: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"matrix-js",
|
||||
"account",
|
||||
"add",
|
||||
"--account",
|
||||
"Ops",
|
||||
"--homeserver",
|
||||
"https://matrix.example.org",
|
||||
"--user-id",
|
||||
"@ops:example.org",
|
||||
"--password",
|
||||
"secret",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(matrixSetupValidateInputMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "ops",
|
||||
input: expect.objectContaining({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
password: "secret",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
accounts: {
|
||||
ops: expect.objectContaining({
|
||||
homeserver: "https://matrix.example.org",
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith("Saved matrix-js account: ops");
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"Bind this account to an agent: openclaw agents bind --agent <id> --bind matrix-js:ops",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses --name as fallback account id and prints account-scoped config path", async () => {
|
||||
matrixRuntimeLoadConfigMock.mockReturnValue({ channels: {} });
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"matrix-js",
|
||||
"account",
|
||||
"add",
|
||||
"--name",
|
||||
"Main Bot",
|
||||
"--homeserver",
|
||||
"https://matrix.example.org",
|
||||
"--user-id",
|
||||
"@main:example.org",
|
||||
"--password",
|
||||
"secret",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(matrixSetupValidateInputMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "main-bot",
|
||||
}),
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith("Saved matrix-js account: main-bot");
|
||||
expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix-js.accounts.main-bot");
|
||||
expect(updateMatrixOwnProfileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "main-bot",
|
||||
displayName: "Main Bot",
|
||||
}),
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"Bind this account to an agent: openclaw agents bind --agent <id> --bind matrix-js:main-bot",
|
||||
);
|
||||
});
|
||||
|
||||
it("sets profile name and avatar via profile set command", async () => {
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"matrix-js",
|
||||
"profile",
|
||||
"set",
|
||||
"--account",
|
||||
"alerts",
|
||||
"--name",
|
||||
"Alerts Bot",
|
||||
"--avatar-url",
|
||||
"mxc://example/avatar",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(updateMatrixOwnProfileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "alerts",
|
||||
displayName: "Alerts Bot",
|
||||
avatarUrl: "mxc://example/avatar",
|
||||
}),
|
||||
);
|
||||
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled();
|
||||
expect(console.log).toHaveBeenCalledWith("Account: alerts");
|
||||
expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix-js.accounts.alerts");
|
||||
});
|
||||
|
||||
it("returns JSON errors for invalid account setup input", async () => {
|
||||
matrixSetupValidateInputMock.mockReturnValue("Matrix requires --homeserver");
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix-js", "account", "add", "--json"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"error": "Matrix requires --homeserver"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps zero exit code for successful bootstrap in JSON mode", async () => {
|
||||
process.exitCode = 0;
|
||||
bootstrapMatrixVerificationMock.mockResolvedValue({
|
||||
success: true,
|
||||
verification: {},
|
||||
crossSigning: {},
|
||||
pendingVerifications: 0,
|
||||
cryptoBootstrap: {},
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix-js", "verify", "bootstrap", "--json"], { from: "user" });
|
||||
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("prints local timezone timestamps for verify status output in verbose mode", async () => {
|
||||
const recoveryCreatedAt = "2026-02-25T20:10:11.000Z";
|
||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||
encryptionEnabled: true,
|
||||
verified: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "1",
|
||||
backup: {
|
||||
serverVersion: "1",
|
||||
activeVersion: "1",
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
decryptionKeyCached: true,
|
||||
},
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyCreatedAt: recoveryCreatedAt,
|
||||
pendingVerifications: 0,
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix-js", "verify", "status", "--verbose"], { from: "user" });
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
`Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`,
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith("Diagnostics:");
|
||||
expect(setMatrixSdkLogModeMock).toHaveBeenCalledWith("default");
|
||||
});
|
||||
|
||||
it("prints local timezone timestamps for verify bootstrap and device output in verbose mode", async () => {
|
||||
const recoveryCreatedAt = "2026-02-25T20:10:11.000Z";
|
||||
const verifiedAt = "2026-02-25T20:14:00.000Z";
|
||||
bootstrapMatrixVerificationMock.mockResolvedValue({
|
||||
success: true,
|
||||
verification: {
|
||||
encryptionEnabled: true,
|
||||
verified: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "1",
|
||||
backup: {
|
||||
serverVersion: "1",
|
||||
activeVersion: "1",
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
decryptionKeyCached: true,
|
||||
},
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyId: "SSSS",
|
||||
recoveryKeyCreatedAt: recoveryCreatedAt,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
},
|
||||
crossSigning: {
|
||||
published: true,
|
||||
masterKeyPublished: true,
|
||||
selfSigningKeyPublished: true,
|
||||
userSigningKeyPublished: true,
|
||||
},
|
||||
pendingVerifications: 0,
|
||||
cryptoBootstrap: {},
|
||||
});
|
||||
verifyMatrixRecoveryKeyMock.mockResolvedValue({
|
||||
success: true,
|
||||
encryptionEnabled: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "1",
|
||||
backup: {
|
||||
serverVersion: "1",
|
||||
activeVersion: "1",
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
decryptionKeyCached: true,
|
||||
},
|
||||
verified: true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyId: "SSSS",
|
||||
recoveryKeyCreatedAt: recoveryCreatedAt,
|
||||
verifiedAt,
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix-js", "verify", "bootstrap", "--verbose"], {
|
||||
from: "user",
|
||||
});
|
||||
await program.parseAsync(["matrix-js", "verify", "device", "valid-key", "--verbose"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
`Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`,
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
`Verified at: ${formatExpectedLocalTimestamp(verifiedAt)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps default output concise when verbose is not provided", async () => {
|
||||
const recoveryCreatedAt = "2026-02-25T20:10:11.000Z";
|
||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||
encryptionEnabled: true,
|
||||
verified: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "1",
|
||||
backup: {
|
||||
serverVersion: "1",
|
||||
activeVersion: "1",
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
decryptionKeyCached: true,
|
||||
},
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyCreatedAt: recoveryCreatedAt,
|
||||
pendingVerifications: 0,
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix-js", "verify", "status"], { from: "user" });
|
||||
|
||||
expect(console.log).not.toHaveBeenCalledWith(
|
||||
`Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`,
|
||||
);
|
||||
expect(console.log).not.toHaveBeenCalledWith("Pending verifications: 0");
|
||||
expect(console.log).not.toHaveBeenCalledWith("Diagnostics:");
|
||||
expect(console.log).toHaveBeenCalledWith("Backup: active and trusted on this device");
|
||||
expect(setMatrixSdkLogModeMock).toHaveBeenCalledWith("quiet");
|
||||
});
|
||||
|
||||
it("shows explicit backup issue in default status output", async () => {
|
||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||
encryptionEnabled: true,
|
||||
verified: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "5256",
|
||||
backup: {
|
||||
serverVersion: "5256",
|
||||
activeVersion: null,
|
||||
trusted: true,
|
||||
matchesDecryptionKey: false,
|
||||
decryptionKeyCached: false,
|
||||
keyLoadAttempted: true,
|
||||
keyLoadError: null,
|
||||
},
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyCreatedAt: "2026-02-25T20:10:11.000Z",
|
||||
pendingVerifications: 0,
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix-js", "verify", "status"], { from: "user" });
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"Backup issue: backup decryption key is not loaded on this device (secret storage did not return a key)",
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"- Backup key is not loaded on this device. Run 'openclaw matrix-js verify backup restore' to load it and restore old room keys.",
|
||||
);
|
||||
expect(console.log).not.toHaveBeenCalledWith(
|
||||
"- Backup is present but not trusted for this device. Re-run 'openclaw matrix-js verify device <key>'.",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes key load failure details in status output", async () => {
|
||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||
encryptionEnabled: true,
|
||||
verified: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "5256",
|
||||
backup: {
|
||||
serverVersion: "5256",
|
||||
activeVersion: null,
|
||||
trusted: true,
|
||||
matchesDecryptionKey: false,
|
||||
decryptionKeyCached: false,
|
||||
keyLoadAttempted: true,
|
||||
keyLoadError: "secret storage key is not available",
|
||||
},
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyCreatedAt: "2026-02-25T20:10:11.000Z",
|
||||
pendingVerifications: 0,
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix-js", "verify", "status"], { from: "user" });
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"Backup issue: backup decryption key could not be loaded from secret storage (secret storage key is not available)",
|
||||
);
|
||||
});
|
||||
|
||||
it("prints backup health lines for verify backup status in verbose mode", async () => {
|
||||
getMatrixRoomKeyBackupStatusMock.mockResolvedValue({
|
||||
serverVersion: "2",
|
||||
activeVersion: null,
|
||||
trusted: true,
|
||||
matchesDecryptionKey: false,
|
||||
decryptionKeyCached: false,
|
||||
keyLoadAttempted: true,
|
||||
keyLoadError: null,
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix-js", "verify", "backup", "status", "--verbose"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith("Backup server version: 2");
|
||||
expect(console.log).toHaveBeenCalledWith("Backup active on this device: no");
|
||||
expect(console.log).toHaveBeenCalledWith("Backup trusted by this device: yes");
|
||||
});
|
||||
});
|
||||
871
extensions/matrix-js/src/cli.ts
Normal file
871
extensions/matrix-js/src/cli.ts
Normal file
@@ -0,0 +1,871 @@
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
formatZonedTimestamp,
|
||||
normalizeAccountId,
|
||||
type ChannelSetupInput,
|
||||
} from "openclaw/plugin-sdk/matrix-js";
|
||||
import { matrixPlugin } from "./channel.js";
|
||||
import { updateMatrixOwnProfile } from "./matrix/actions/profile.js";
|
||||
import {
|
||||
bootstrapMatrixVerification,
|
||||
getMatrixRoomKeyBackupStatus,
|
||||
getMatrixVerificationStatus,
|
||||
restoreMatrixRoomKeyBackup,
|
||||
verifyMatrixRecoveryKey,
|
||||
} from "./matrix/actions/verification.js";
|
||||
import { setMatrixSdkLogMode } from "./matrix/client/logging.js";
|
||||
import { updateMatrixAccountConfig } from "./matrix/config-update.js";
|
||||
import { getMatrixRuntime } from "./runtime.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
let matrixJsCliExitScheduled = false;
|
||||
|
||||
function scheduleMatrixJsCliExit(): void {
|
||||
if (matrixJsCliExitScheduled || process.env.VITEST) {
|
||||
return;
|
||||
}
|
||||
matrixJsCliExitScheduled = true;
|
||||
// matrix-js-sdk rust crypto can leave background async work alive after command completion.
|
||||
setTimeout(() => {
|
||||
process.exit(process.exitCode ?? 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function markCliFailure(): void {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
function toErrorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
function printJson(payload: unknown): void {
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
function formatLocalTimestamp(value: string | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (!Number.isFinite(parsed.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return formatZonedTimestamp(parsed, { displaySeconds: true }) ?? value;
|
||||
}
|
||||
|
||||
function printTimestamp(label: string, value: string | null | undefined): void {
|
||||
const formatted = formatLocalTimestamp(value);
|
||||
if (formatted) {
|
||||
console.log(`${label}: ${formatted}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printAccountLabel(accountId?: string): void {
|
||||
console.log(`Account: ${normalizeAccountId(accountId)}`);
|
||||
}
|
||||
|
||||
function configureCliLogMode(verbose: boolean): void {
|
||||
setMatrixSdkLogMode(verbose ? "default" : "quiet");
|
||||
}
|
||||
|
||||
function parseOptionalInt(value: string | undefined, fieldName: string): number | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new Error(`${fieldName} must be an integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
type MatrixCliAccountAddResult = {
|
||||
accountId: string;
|
||||
configPath: string;
|
||||
useEnv: boolean;
|
||||
profile: {
|
||||
attempted: boolean;
|
||||
displayNameUpdated: boolean;
|
||||
avatarUpdated: boolean;
|
||||
resolvedAvatarUrl: string | null;
|
||||
convertedAvatarFromHttp: boolean;
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
|
||||
async function addMatrixJsAccount(params: {
|
||||
account?: string;
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
homeserver?: string;
|
||||
userId?: string;
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: string;
|
||||
useEnv?: boolean;
|
||||
}): Promise<MatrixCliAccountAddResult> {
|
||||
const runtime = getMatrixRuntime();
|
||||
const cfg = runtime.config.loadConfig() as CoreConfig;
|
||||
const setup = matrixPlugin.setup;
|
||||
if (!setup?.applyAccountConfig) {
|
||||
throw new Error("Matrix-js account setup is unavailable.");
|
||||
}
|
||||
|
||||
const input: ChannelSetupInput & { avatarUrl?: string } = {
|
||||
name: params.name,
|
||||
avatarUrl: params.avatarUrl,
|
||||
homeserver: params.homeserver,
|
||||
userId: params.userId,
|
||||
accessToken: params.accessToken,
|
||||
password: params.password,
|
||||
deviceName: params.deviceName,
|
||||
initialSyncLimit: parseOptionalInt(params.initialSyncLimit, "--initial-sync-limit"),
|
||||
useEnv: params.useEnv === true,
|
||||
};
|
||||
const accountId =
|
||||
setup.resolveAccountId?.({
|
||||
cfg,
|
||||
accountId: params.account,
|
||||
input,
|
||||
}) ?? normalizeAccountId(params.account?.trim() || params.name?.trim());
|
||||
|
||||
const validationError = setup.validateInput?.({
|
||||
cfg,
|
||||
accountId,
|
||||
input,
|
||||
});
|
||||
if (validationError) {
|
||||
throw new Error(validationError);
|
||||
}
|
||||
|
||||
const updated = setup.applyAccountConfig({
|
||||
cfg,
|
||||
accountId,
|
||||
input,
|
||||
}) as CoreConfig;
|
||||
await runtime.config.writeConfigFile(updated as never);
|
||||
|
||||
const desiredDisplayName = input.name?.trim();
|
||||
const desiredAvatarUrl = input.avatarUrl?.trim();
|
||||
let profile: MatrixCliAccountAddResult["profile"] = {
|
||||
attempted: false,
|
||||
displayNameUpdated: false,
|
||||
avatarUpdated: false,
|
||||
resolvedAvatarUrl: null,
|
||||
convertedAvatarFromHttp: false,
|
||||
};
|
||||
if (desiredDisplayName || desiredAvatarUrl) {
|
||||
try {
|
||||
const synced = await updateMatrixOwnProfile({
|
||||
accountId,
|
||||
displayName: desiredDisplayName,
|
||||
avatarUrl: desiredAvatarUrl,
|
||||
});
|
||||
let resolvedAvatarUrl = synced.resolvedAvatarUrl;
|
||||
if (synced.convertedAvatarFromHttp && synced.resolvedAvatarUrl) {
|
||||
const latestCfg = runtime.config.loadConfig() as CoreConfig;
|
||||
const withAvatar = updateMatrixAccountConfig(latestCfg, accountId, {
|
||||
avatarUrl: synced.resolvedAvatarUrl,
|
||||
});
|
||||
await runtime.config.writeConfigFile(withAvatar as never);
|
||||
resolvedAvatarUrl = synced.resolvedAvatarUrl;
|
||||
}
|
||||
profile = {
|
||||
attempted: true,
|
||||
displayNameUpdated: synced.displayNameUpdated,
|
||||
avatarUpdated: synced.avatarUpdated,
|
||||
resolvedAvatarUrl,
|
||||
convertedAvatarFromHttp: synced.convertedAvatarFromHttp,
|
||||
};
|
||||
} catch (err) {
|
||||
profile = {
|
||||
attempted: true,
|
||||
displayNameUpdated: false,
|
||||
avatarUpdated: false,
|
||||
resolvedAvatarUrl: null,
|
||||
convertedAvatarFromHttp: false,
|
||||
error: toErrorMessage(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accountId,
|
||||
configPath: `channels.matrix-js.accounts.${accountId}`,
|
||||
useEnv: input.useEnv === true,
|
||||
profile,
|
||||
};
|
||||
}
|
||||
|
||||
type MatrixCliProfileSetResult = {
|
||||
accountId: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
profile: {
|
||||
displayNameUpdated: boolean;
|
||||
avatarUpdated: boolean;
|
||||
resolvedAvatarUrl: string | null;
|
||||
convertedAvatarFromHttp: boolean;
|
||||
};
|
||||
configPath: string;
|
||||
};
|
||||
|
||||
async function setMatrixJsProfile(params: {
|
||||
account?: string;
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
}): Promise<MatrixCliProfileSetResult> {
|
||||
const runtime = getMatrixRuntime();
|
||||
const cfg = runtime.config.loadConfig() as CoreConfig;
|
||||
const accountId = normalizeAccountId(params.account);
|
||||
const displayName = params.name?.trim() || null;
|
||||
const avatarUrl = params.avatarUrl?.trim() || null;
|
||||
if (!displayName && !avatarUrl) {
|
||||
throw new Error("Provide --name and/or --avatar-url.");
|
||||
}
|
||||
|
||||
const synced = await updateMatrixOwnProfile({
|
||||
accountId,
|
||||
displayName: displayName ?? undefined,
|
||||
avatarUrl: avatarUrl ?? undefined,
|
||||
});
|
||||
const persistedAvatarUrl =
|
||||
synced.convertedAvatarFromHttp && synced.resolvedAvatarUrl
|
||||
? synced.resolvedAvatarUrl
|
||||
: avatarUrl;
|
||||
const updated = updateMatrixAccountConfig(cfg, accountId, {
|
||||
name: displayName ?? undefined,
|
||||
avatarUrl: persistedAvatarUrl ?? undefined,
|
||||
});
|
||||
await runtime.config.writeConfigFile(updated as never);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
displayName,
|
||||
avatarUrl: persistedAvatarUrl ?? null,
|
||||
profile: {
|
||||
displayNameUpdated: synced.displayNameUpdated,
|
||||
avatarUpdated: synced.avatarUpdated,
|
||||
resolvedAvatarUrl: synced.resolvedAvatarUrl,
|
||||
convertedAvatarFromHttp: synced.convertedAvatarFromHttp,
|
||||
},
|
||||
configPath: `channels.matrix-js.accounts.${accountId}`,
|
||||
};
|
||||
}
|
||||
|
||||
type MatrixCliCommandConfig<TResult> = {
|
||||
verbose: boolean;
|
||||
json: boolean;
|
||||
run: () => Promise<TResult>;
|
||||
onText: (result: TResult, verbose: boolean) => void;
|
||||
onJson?: (result: TResult) => unknown;
|
||||
shouldFail?: (result: TResult) => boolean;
|
||||
errorPrefix: string;
|
||||
onJsonError?: (message: string) => unknown;
|
||||
};
|
||||
|
||||
async function runMatrixCliCommand<TResult>(
|
||||
config: MatrixCliCommandConfig<TResult>,
|
||||
): Promise<void> {
|
||||
configureCliLogMode(config.verbose);
|
||||
try {
|
||||
const result = await config.run();
|
||||
if (config.json) {
|
||||
printJson(config.onJson ? config.onJson(result) : result);
|
||||
} else {
|
||||
config.onText(result, config.verbose);
|
||||
}
|
||||
if (config.shouldFail?.(result)) {
|
||||
markCliFailure();
|
||||
}
|
||||
} catch (err) {
|
||||
const message = toErrorMessage(err);
|
||||
if (config.json) {
|
||||
printJson(config.onJsonError ? config.onJsonError(message) : { error: message });
|
||||
} else {
|
||||
console.error(`${config.errorPrefix}: ${message}`);
|
||||
}
|
||||
markCliFailure();
|
||||
} finally {
|
||||
scheduleMatrixJsCliExit();
|
||||
}
|
||||
}
|
||||
|
||||
type MatrixCliBackupStatus = {
|
||||
serverVersion: string | null;
|
||||
activeVersion: string | null;
|
||||
trusted: boolean | null;
|
||||
matchesDecryptionKey: boolean | null;
|
||||
decryptionKeyCached: boolean | null;
|
||||
keyLoadAttempted: boolean;
|
||||
keyLoadError: string | null;
|
||||
};
|
||||
|
||||
type MatrixCliVerificationStatus = {
|
||||
encryptionEnabled: boolean;
|
||||
verified: boolean;
|
||||
userId: string | null;
|
||||
deviceId: string | null;
|
||||
backupVersion: string | null;
|
||||
backup?: MatrixCliBackupStatus;
|
||||
recoveryKeyStored: boolean;
|
||||
recoveryKeyCreatedAt: string | null;
|
||||
pendingVerifications: number;
|
||||
};
|
||||
|
||||
function resolveBackupStatus(status: {
|
||||
backupVersion: string | null;
|
||||
backup?: MatrixCliBackupStatus;
|
||||
}): MatrixCliBackupStatus {
|
||||
return {
|
||||
serverVersion: status.backup?.serverVersion ?? status.backupVersion ?? null,
|
||||
activeVersion: status.backup?.activeVersion ?? null,
|
||||
trusted: status.backup?.trusted ?? null,
|
||||
matchesDecryptionKey: status.backup?.matchesDecryptionKey ?? null,
|
||||
decryptionKeyCached: status.backup?.decryptionKeyCached ?? null,
|
||||
keyLoadAttempted: status.backup?.keyLoadAttempted ?? false,
|
||||
keyLoadError: status.backup?.keyLoadError ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
type MatrixCliBackupIssueCode =
|
||||
| "missing-server-backup"
|
||||
| "key-load-failed"
|
||||
| "key-not-loaded"
|
||||
| "key-mismatch"
|
||||
| "untrusted-signature"
|
||||
| "inactive"
|
||||
| "indeterminate"
|
||||
| "ok";
|
||||
|
||||
type MatrixCliBackupIssue = {
|
||||
code: MatrixCliBackupIssueCode;
|
||||
summary: string;
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
function yesNoUnknown(value: boolean | null): string {
|
||||
if (value === true) {
|
||||
return "yes";
|
||||
}
|
||||
if (value === false) {
|
||||
return "no";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function printBackupStatus(backup: MatrixCliBackupStatus): void {
|
||||
console.log(`Backup server version: ${backup.serverVersion ?? "none"}`);
|
||||
console.log(`Backup active on this device: ${backup.activeVersion ?? "no"}`);
|
||||
console.log(`Backup trusted by this device: ${yesNoUnknown(backup.trusted)}`);
|
||||
console.log(`Backup matches local decryption key: ${yesNoUnknown(backup.matchesDecryptionKey)}`);
|
||||
console.log(`Backup key cached locally: ${yesNoUnknown(backup.decryptionKeyCached)}`);
|
||||
console.log(`Backup key load attempted: ${yesNoUnknown(backup.keyLoadAttempted)}`);
|
||||
if (backup.keyLoadError) {
|
||||
console.log(`Backup key load error: ${backup.keyLoadError}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printVerificationIdentity(status: {
|
||||
userId: string | null;
|
||||
deviceId: string | null;
|
||||
}): void {
|
||||
console.log(`User: ${status.userId ?? "unknown"}`);
|
||||
console.log(`Device: ${status.deviceId ?? "unknown"}`);
|
||||
}
|
||||
|
||||
function printVerificationBackupSummary(status: {
|
||||
backupVersion: string | null;
|
||||
backup?: MatrixCliBackupStatus;
|
||||
}): void {
|
||||
printBackupSummary(resolveBackupStatus(status));
|
||||
}
|
||||
|
||||
function printVerificationBackupStatus(status: {
|
||||
backupVersion: string | null;
|
||||
backup?: MatrixCliBackupStatus;
|
||||
}): void {
|
||||
printBackupStatus(resolveBackupStatus(status));
|
||||
}
|
||||
|
||||
function printVerificationGuidance(status: MatrixCliVerificationStatus): void {
|
||||
printGuidance(buildVerificationGuidance(status));
|
||||
}
|
||||
|
||||
function resolveBackupIssue(backup: MatrixCliBackupStatus): MatrixCliBackupIssue {
|
||||
if (!backup.serverVersion) {
|
||||
return {
|
||||
code: "missing-server-backup",
|
||||
summary: "missing on server",
|
||||
message: "no room-key backup exists on the homeserver",
|
||||
};
|
||||
}
|
||||
if (backup.decryptionKeyCached === false) {
|
||||
if (backup.keyLoadError) {
|
||||
return {
|
||||
code: "key-load-failed",
|
||||
summary: "present but backup key unavailable on this device",
|
||||
message: `backup decryption key could not be loaded from secret storage (${backup.keyLoadError})`,
|
||||
};
|
||||
}
|
||||
if (backup.keyLoadAttempted) {
|
||||
return {
|
||||
code: "key-not-loaded",
|
||||
summary: "present but backup key unavailable on this device",
|
||||
message:
|
||||
"backup decryption key is not loaded on this device (secret storage did not return a key)",
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: "key-not-loaded",
|
||||
summary: "present but backup key unavailable on this device",
|
||||
message: "backup decryption key is not loaded on this device",
|
||||
};
|
||||
}
|
||||
if (backup.matchesDecryptionKey === false) {
|
||||
return {
|
||||
code: "key-mismatch",
|
||||
summary: "present but backup key mismatch on this device",
|
||||
message: "backup key mismatch (this device does not have the matching backup decryption key)",
|
||||
};
|
||||
}
|
||||
if (backup.trusted === false) {
|
||||
return {
|
||||
code: "untrusted-signature",
|
||||
summary: "present but not trusted on this device",
|
||||
message: "backup signature chain is not trusted by this device",
|
||||
};
|
||||
}
|
||||
if (!backup.activeVersion) {
|
||||
return {
|
||||
code: "inactive",
|
||||
summary: "present on server but inactive on this device",
|
||||
message: "backup exists but is not active on this device",
|
||||
};
|
||||
}
|
||||
if (
|
||||
backup.trusted === null ||
|
||||
backup.matchesDecryptionKey === null ||
|
||||
backup.decryptionKeyCached === null
|
||||
) {
|
||||
return {
|
||||
code: "indeterminate",
|
||||
summary: "present but trust state unknown",
|
||||
message: "backup trust state could not be fully determined",
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: "ok",
|
||||
summary: "active and trusted on this device",
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
function printBackupSummary(backup: MatrixCliBackupStatus): void {
|
||||
const issue = resolveBackupIssue(backup);
|
||||
console.log(`Backup: ${issue.summary}`);
|
||||
if (backup.serverVersion) {
|
||||
console.log(`Backup version: ${backup.serverVersion}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildVerificationGuidance(status: MatrixCliVerificationStatus): string[] {
|
||||
const backup = resolveBackupStatus(status);
|
||||
const backupIssue = resolveBackupIssue(backup);
|
||||
const nextSteps = new Set<string>();
|
||||
if (!status.verified) {
|
||||
nextSteps.add("Run 'openclaw matrix-js verify device <key>' to verify this device.");
|
||||
}
|
||||
if (backupIssue.code === "missing-server-backup") {
|
||||
nextSteps.add("Run 'openclaw matrix-js verify bootstrap' to create a room key backup.");
|
||||
} else if (
|
||||
backupIssue.code === "key-load-failed" ||
|
||||
backupIssue.code === "key-not-loaded" ||
|
||||
backupIssue.code === "inactive"
|
||||
) {
|
||||
if (status.recoveryKeyStored) {
|
||||
nextSteps.add(
|
||||
"Backup key is not loaded on this device. Run 'openclaw matrix-js verify backup restore' to load it and restore old room keys.",
|
||||
);
|
||||
} else {
|
||||
nextSteps.add(
|
||||
"Store a recovery key with 'openclaw matrix-js verify device <key>', then run 'openclaw matrix-js verify backup restore'.",
|
||||
);
|
||||
}
|
||||
} else if (backupIssue.code === "key-mismatch") {
|
||||
nextSteps.add(
|
||||
"Backup key mismatch on this device. Re-run 'openclaw matrix-js verify device <key>' with the matching recovery key.",
|
||||
);
|
||||
} else if (backupIssue.code === "untrusted-signature") {
|
||||
nextSteps.add(
|
||||
"Backup trust chain is not verified on this device. Re-run 'openclaw matrix-js verify device <key>'.",
|
||||
);
|
||||
} else if (backupIssue.code === "indeterminate") {
|
||||
nextSteps.add(
|
||||
"Run 'openclaw matrix-js verify status --verbose' to inspect backup trust diagnostics.",
|
||||
);
|
||||
}
|
||||
if (status.pendingVerifications > 0) {
|
||||
nextSteps.add(`Complete ${status.pendingVerifications} pending verification request(s).`);
|
||||
}
|
||||
return Array.from(nextSteps);
|
||||
}
|
||||
|
||||
function printGuidance(lines: string[]): void {
|
||||
if (lines.length === 0) {
|
||||
return;
|
||||
}
|
||||
console.log("Next steps:");
|
||||
for (const line of lines) {
|
||||
console.log(`- ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printVerificationStatus(status: MatrixCliVerificationStatus, verbose = false): void {
|
||||
console.log(`Verified: ${status.verified ? "yes" : "no"}`);
|
||||
const backup = resolveBackupStatus(status);
|
||||
const backupIssue = resolveBackupIssue(backup);
|
||||
printVerificationBackupSummary(status);
|
||||
if (backupIssue.message) {
|
||||
console.log(`Backup issue: ${backupIssue.message}`);
|
||||
}
|
||||
if (verbose) {
|
||||
console.log("Diagnostics:");
|
||||
printVerificationIdentity(status);
|
||||
printVerificationBackupStatus(status);
|
||||
console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`);
|
||||
printTimestamp("Recovery key created at", status.recoveryKeyCreatedAt);
|
||||
console.log(`Pending verifications: ${status.pendingVerifications}`);
|
||||
} else {
|
||||
console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`);
|
||||
}
|
||||
printVerificationGuidance(status);
|
||||
}
|
||||
|
||||
export function registerMatrixJsCli(params: { program: Command }): void {
|
||||
const root = params.program
|
||||
.command("matrix-js")
|
||||
.description("Matrix-js channel utilities")
|
||||
.addHelpText("after", () => "\nDocs: https://docs.openclaw.ai/channels/matrix-js\n");
|
||||
|
||||
const account = root.command("account").description("Manage matrix-js channel accounts");
|
||||
|
||||
account
|
||||
.command("add")
|
||||
.description("Add or update a matrix-js account (wrapper around channel setup)")
|
||||
.option("--account <id>", "Account ID (default: normalized --name, else default)")
|
||||
.option("--name <name>", "Optional display name for this account")
|
||||
.option("--avatar-url <url>", "Optional Matrix avatar URL (mxc:// or http(s) URL)")
|
||||
.option("--homeserver <url>", "Matrix homeserver URL")
|
||||
.option("--user-id <id>", "Matrix user ID")
|
||||
.option("--access-token <token>", "Matrix access token")
|
||||
.option("--password <password>", "Matrix password")
|
||||
.option("--device-name <name>", "Matrix device display name")
|
||||
.option("--initial-sync-limit <n>", "Matrix initial sync limit")
|
||||
.option(
|
||||
"--use-env",
|
||||
"Use MATRIX_* env vars (or MATRIX_<ACCOUNT_ID>_* for non-default accounts)",
|
||||
)
|
||||
.option("--verbose", "Show setup details")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(
|
||||
async (options: {
|
||||
account?: string;
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
homeserver?: string;
|
||||
userId?: string;
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: string;
|
||||
useEnv?: boolean;
|
||||
verbose?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
await runMatrixCliCommand({
|
||||
verbose: options.verbose === true,
|
||||
json: options.json === true,
|
||||
run: async () =>
|
||||
await addMatrixJsAccount({
|
||||
account: options.account,
|
||||
name: options.name,
|
||||
avatarUrl: options.avatarUrl,
|
||||
homeserver: options.homeserver,
|
||||
userId: options.userId,
|
||||
accessToken: options.accessToken,
|
||||
password: options.password,
|
||||
deviceName: options.deviceName,
|
||||
initialSyncLimit: options.initialSyncLimit,
|
||||
useEnv: options.useEnv === true,
|
||||
}),
|
||||
onText: (result) => {
|
||||
console.log(`Saved matrix-js account: ${result.accountId}`);
|
||||
console.log(`Config path: ${result.configPath}`);
|
||||
console.log(
|
||||
`Credentials source: ${result.useEnv ? "MATRIX_* / MATRIX_<ACCOUNT_ID>_* env vars" : "inline config"}`,
|
||||
);
|
||||
if (result.profile.attempted) {
|
||||
if (result.profile.error) {
|
||||
console.error(`Profile sync warning: ${result.profile.error}`);
|
||||
} else {
|
||||
console.log(
|
||||
`Profile sync: name ${result.profile.displayNameUpdated ? "updated" : "unchanged"}, avatar ${result.profile.avatarUpdated ? "updated" : "unchanged"}`,
|
||||
);
|
||||
if (result.profile.convertedAvatarFromHttp && result.profile.resolvedAvatarUrl) {
|
||||
console.log(`Avatar converted and saved as: ${result.profile.resolvedAvatarUrl}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const bindHint = `openclaw agents bind --agent <id> --bind matrix-js:${result.accountId}`;
|
||||
console.log(`Bind this account to an agent: ${bindHint}`);
|
||||
},
|
||||
errorPrefix: "Account setup failed",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const profile = root.command("profile").description("Manage Matrix-js bot profile");
|
||||
|
||||
profile
|
||||
.command("set")
|
||||
.description("Update Matrix profile display name and/or avatar")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--name <name>", "Profile display name")
|
||||
.option("--avatar-url <url>", "Profile avatar URL (mxc:// or http(s) URL)")
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(
|
||||
async (options: {
|
||||
account?: string;
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
verbose?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
await runMatrixCliCommand({
|
||||
verbose: options.verbose === true,
|
||||
json: options.json === true,
|
||||
run: async () =>
|
||||
await setMatrixJsProfile({
|
||||
account: options.account,
|
||||
name: options.name,
|
||||
avatarUrl: options.avatarUrl,
|
||||
}),
|
||||
onText: (result) => {
|
||||
printAccountLabel(result.accountId);
|
||||
console.log(`Config path: ${result.configPath}`);
|
||||
console.log(
|
||||
`Profile update: name ${result.profile.displayNameUpdated ? "updated" : "unchanged"}, avatar ${result.profile.avatarUpdated ? "updated" : "unchanged"}`,
|
||||
);
|
||||
if (result.profile.convertedAvatarFromHttp && result.avatarUrl) {
|
||||
console.log(`Avatar converted and saved as: ${result.avatarUrl}`);
|
||||
}
|
||||
},
|
||||
errorPrefix: "Profile update failed",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const verify = root.command("verify").description("Device verification for Matrix E2EE");
|
||||
|
||||
verify
|
||||
.command("status")
|
||||
.description("Check Matrix-js device verification status")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--include-recovery-key", "Include stored recovery key in output")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(
|
||||
async (options: {
|
||||
account?: string;
|
||||
verbose?: boolean;
|
||||
includeRecoveryKey?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
await runMatrixCliCommand({
|
||||
verbose: options.verbose === true,
|
||||
json: options.json === true,
|
||||
run: async () =>
|
||||
await getMatrixVerificationStatus({
|
||||
accountId: options.account,
|
||||
includeRecoveryKey: options.includeRecoveryKey === true,
|
||||
}),
|
||||
onText: (status, verbose) => {
|
||||
printAccountLabel(options.account);
|
||||
printVerificationStatus(status, verbose);
|
||||
},
|
||||
errorPrefix: "Error",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const backup = verify.command("backup").description("Matrix room-key backup health and restore");
|
||||
|
||||
backup
|
||||
.command("status")
|
||||
.description("Show Matrix room-key backup status for this device")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (options: { account?: string; verbose?: boolean; json?: boolean }) => {
|
||||
await runMatrixCliCommand({
|
||||
verbose: options.verbose === true,
|
||||
json: options.json === true,
|
||||
run: async () => await getMatrixRoomKeyBackupStatus({ accountId: options.account }),
|
||||
onText: (status, verbose) => {
|
||||
printAccountLabel(options.account);
|
||||
printBackupSummary(status);
|
||||
if (verbose) {
|
||||
printBackupStatus(status);
|
||||
}
|
||||
},
|
||||
errorPrefix: "Backup status failed",
|
||||
});
|
||||
});
|
||||
|
||||
backup
|
||||
.command("restore")
|
||||
.description("Restore encrypted room keys from server backup")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--recovery-key <key>", "Optional recovery key to load before restoring")
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(
|
||||
async (options: {
|
||||
account?: string;
|
||||
recoveryKey?: string;
|
||||
verbose?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
await runMatrixCliCommand({
|
||||
verbose: options.verbose === true,
|
||||
json: options.json === true,
|
||||
run: async () =>
|
||||
await restoreMatrixRoomKeyBackup({
|
||||
accountId: options.account,
|
||||
recoveryKey: options.recoveryKey,
|
||||
}),
|
||||
onText: (result, verbose) => {
|
||||
printAccountLabel(options.account);
|
||||
console.log(`Restore success: ${result.success ? "yes" : "no"}`);
|
||||
if (result.error) {
|
||||
console.log(`Error: ${result.error}`);
|
||||
}
|
||||
console.log(`Backup version: ${result.backupVersion ?? "none"}`);
|
||||
console.log(`Imported keys: ${result.imported}/${result.total}`);
|
||||
printBackupSummary(result.backup);
|
||||
if (verbose) {
|
||||
console.log(
|
||||
`Loaded key from secret storage: ${result.loadedFromSecretStorage ? "yes" : "no"}`,
|
||||
);
|
||||
printTimestamp("Restored at", result.restoredAt);
|
||||
printBackupStatus(result.backup);
|
||||
}
|
||||
},
|
||||
shouldFail: (result) => !result.success,
|
||||
errorPrefix: "Backup restore failed",
|
||||
onJsonError: (message) => ({ success: false, error: message }),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
verify
|
||||
.command("bootstrap")
|
||||
.description("Bootstrap Matrix-js cross-signing and device verification state")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--recovery-key <key>", "Recovery key to apply before bootstrap")
|
||||
.option("--force-reset-cross-signing", "Force reset cross-signing identity before bootstrap")
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(
|
||||
async (options: {
|
||||
account?: string;
|
||||
recoveryKey?: string;
|
||||
forceResetCrossSigning?: boolean;
|
||||
verbose?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
await runMatrixCliCommand({
|
||||
verbose: options.verbose === true,
|
||||
json: options.json === true,
|
||||
run: async () =>
|
||||
await bootstrapMatrixVerification({
|
||||
accountId: options.account,
|
||||
recoveryKey: options.recoveryKey,
|
||||
forceResetCrossSigning: options.forceResetCrossSigning === true,
|
||||
}),
|
||||
onText: (result, verbose) => {
|
||||
printAccountLabel(options.account);
|
||||
console.log(`Bootstrap success: ${result.success ? "yes" : "no"}`);
|
||||
if (result.error) {
|
||||
console.log(`Error: ${result.error}`);
|
||||
}
|
||||
console.log(`Verified: ${result.verification.verified ? "yes" : "no"}`);
|
||||
printVerificationIdentity(result.verification);
|
||||
if (verbose) {
|
||||
console.log(
|
||||
`Cross-signing published: ${result.crossSigning.published ? "yes" : "no"} (master=${result.crossSigning.masterKeyPublished ? "yes" : "no"}, self=${result.crossSigning.selfSigningKeyPublished ? "yes" : "no"}, user=${result.crossSigning.userSigningKeyPublished ? "yes" : "no"})`,
|
||||
);
|
||||
printVerificationBackupStatus(result.verification);
|
||||
printTimestamp("Recovery key created at", result.verification.recoveryKeyCreatedAt);
|
||||
console.log(`Pending verifications: ${result.pendingVerifications}`);
|
||||
} else {
|
||||
console.log(
|
||||
`Cross-signing published: ${result.crossSigning.published ? "yes" : "no"}`,
|
||||
);
|
||||
printVerificationBackupSummary(result.verification);
|
||||
}
|
||||
printVerificationGuidance({
|
||||
...result.verification,
|
||||
pendingVerifications: result.pendingVerifications,
|
||||
});
|
||||
},
|
||||
shouldFail: (result) => !result.success,
|
||||
errorPrefix: "Verification bootstrap failed",
|
||||
onJsonError: (message) => ({ success: false, error: message }),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
verify
|
||||
.command("device <key>")
|
||||
.description("Verify device using a Matrix recovery key")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(
|
||||
async (key: string, options: { account?: string; verbose?: boolean; json?: boolean }) => {
|
||||
await runMatrixCliCommand({
|
||||
verbose: options.verbose === true,
|
||||
json: options.json === true,
|
||||
run: async () => await verifyMatrixRecoveryKey(key, { accountId: options.account }),
|
||||
onText: (result, verbose) => {
|
||||
printAccountLabel(options.account);
|
||||
if (!result.success) {
|
||||
console.error(`Verification failed: ${result.error ?? "unknown error"}`);
|
||||
return;
|
||||
}
|
||||
console.log("Device verification completed successfully.");
|
||||
printVerificationIdentity(result);
|
||||
printVerificationBackupSummary(result);
|
||||
if (verbose) {
|
||||
printVerificationBackupStatus(result);
|
||||
printTimestamp("Recovery key created at", result.recoveryKeyCreatedAt);
|
||||
printTimestamp("Verified at", result.verifiedAt);
|
||||
}
|
||||
printVerificationGuidance({
|
||||
...result,
|
||||
pendingVerifications: 0,
|
||||
});
|
||||
},
|
||||
shouldFail: (result) => !result.success,
|
||||
errorPrefix: "Verification failed",
|
||||
onJsonError: (message) => ({ success: false, error: message }),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
133
extensions/matrix-js/src/config-migration.ts
Normal file
133
extensions/matrix-js/src/config-migration.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/matrix-js";
|
||||
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "./types.js";
|
||||
|
||||
type LegacyAccountField =
|
||||
| "name"
|
||||
| "homeserver"
|
||||
| "userId"
|
||||
| "accessToken"
|
||||
| "password"
|
||||
| "deviceId"
|
||||
| "deviceName"
|
||||
| "initialSyncLimit"
|
||||
| "encryption"
|
||||
| "allowlistOnly"
|
||||
| "groupPolicy"
|
||||
| "groupAllowFrom"
|
||||
| "replyToMode"
|
||||
| "threadReplies"
|
||||
| "textChunkLimit"
|
||||
| "chunkMode"
|
||||
| "responsePrefix"
|
||||
| "mediaMaxMb"
|
||||
| "autoJoin"
|
||||
| "autoJoinAllowlist"
|
||||
| "dm"
|
||||
| "groups"
|
||||
| "rooms"
|
||||
| "actions";
|
||||
|
||||
const LEGACY_ACCOUNT_FIELDS: ReadonlyArray<LegacyAccountField> = [
|
||||
"name",
|
||||
"homeserver",
|
||||
"userId",
|
||||
"accessToken",
|
||||
"password",
|
||||
"deviceId",
|
||||
"deviceName",
|
||||
"initialSyncLimit",
|
||||
"encryption",
|
||||
"allowlistOnly",
|
||||
"groupPolicy",
|
||||
"groupAllowFrom",
|
||||
"replyToMode",
|
||||
"threadReplies",
|
||||
"textChunkLimit",
|
||||
"chunkMode",
|
||||
"responsePrefix",
|
||||
"mediaMaxMb",
|
||||
"autoJoin",
|
||||
"autoJoinAllowlist",
|
||||
"dm",
|
||||
"groups",
|
||||
"rooms",
|
||||
"actions",
|
||||
];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function mergeLegacyFieldIntoDefault(
|
||||
current: MatrixAccountConfig[LegacyAccountField] | undefined,
|
||||
legacy: MatrixAccountConfig[LegacyAccountField],
|
||||
): MatrixAccountConfig[LegacyAccountField] {
|
||||
if (current === undefined) {
|
||||
return legacy;
|
||||
}
|
||||
if (isRecord(current) && isRecord(legacy)) {
|
||||
return {
|
||||
...legacy,
|
||||
...current,
|
||||
} as MatrixAccountConfig[LegacyAccountField];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function clearLegacyOnlyFields(nextMatrix: MatrixConfig): void {
|
||||
// Legacy matrix-bot-sdk onboarding toggle; not used by matrix-js config.
|
||||
delete (nextMatrix as Record<string, unknown>).register;
|
||||
}
|
||||
|
||||
export function migrateMatrixLegacyCredentialsToDefaultAccount(cfg: CoreConfig): CoreConfig {
|
||||
const matrix = cfg.channels?.["matrix-js"];
|
||||
if (!matrix) {
|
||||
return cfg;
|
||||
}
|
||||
|
||||
const defaultAccount = {
|
||||
...(matrix.accounts?.[DEFAULT_ACCOUNT_ID] ?? {}),
|
||||
} as MatrixAccountConfig;
|
||||
let changed = false;
|
||||
|
||||
for (const field of LEGACY_ACCOUNT_FIELDS) {
|
||||
const legacyValue = matrix[field] as MatrixAccountConfig[LegacyAccountField] | undefined;
|
||||
if (legacyValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
(
|
||||
defaultAccount as Record<
|
||||
LegacyAccountField,
|
||||
MatrixAccountConfig[LegacyAccountField] | undefined
|
||||
>
|
||||
)[field] = mergeLegacyFieldIntoDefault(defaultAccount[field], legacyValue);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const registerPresent = (matrix as Record<string, unknown>).register !== undefined;
|
||||
if (registerPresent) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return cfg;
|
||||
}
|
||||
|
||||
const nextMatrix = { ...matrix } as MatrixConfig;
|
||||
for (const field of LEGACY_ACCOUNT_FIELDS) {
|
||||
delete nextMatrix[field];
|
||||
}
|
||||
clearLegacyOnlyFields(nextMatrix);
|
||||
nextMatrix.accounts = {
|
||||
...matrix.accounts,
|
||||
[DEFAULT_ACCOUNT_ID]: defaultAccount,
|
||||
};
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
"matrix-js": nextMatrix,
|
||||
},
|
||||
};
|
||||
}
|
||||
66
extensions/matrix-js/src/config-schema.ts
Normal file
66
extensions/matrix-js/src/config-schema.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/matrix-js";
|
||||
import { z } from "zod";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
|
||||
const matrixActionSchema = z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
messages: z.boolean().optional(),
|
||||
pins: z.boolean().optional(),
|
||||
memberInfo: z.boolean().optional(),
|
||||
channelInfo: z.boolean().optional(),
|
||||
verification: z.boolean().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const matrixDmSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||
allowFrom: z.array(allowFromEntry).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const matrixRoomSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
allow: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
autoReply: z.boolean().optional(),
|
||||
users: z.array(allowFromEntry).optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const MatrixConfigSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
homeserver: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
accessToken: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
deviceId: z.string().optional(),
|
||||
deviceName: z.string().optional(),
|
||||
avatarUrl: z.string().optional(),
|
||||
initialSyncLimit: z.number().optional(),
|
||||
encryption: z.boolean().optional(),
|
||||
allowlistOnly: z.boolean().optional(),
|
||||
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
|
||||
replyToMode: z.enum(["off", "first", "all"]).optional(),
|
||||
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
|
||||
textChunkLimit: z.number().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
|
||||
autoJoinAllowlist: z.array(allowFromEntry).optional(),
|
||||
groupAllowFrom: z.array(allowFromEntry).optional(),
|
||||
dm: matrixDmSchema,
|
||||
groups: z.object({}).catchall(matrixRoomSchema).optional(),
|
||||
rooms: z.object({}).catchall(matrixRoomSchema).optional(),
|
||||
actions: matrixActionSchema,
|
||||
});
|
||||
74
extensions/matrix-js/src/directory-live.test.ts
Normal file
74
extensions/matrix-js/src/directory-live.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
|
||||
import { resolveMatrixAuth } from "./matrix/client.js";
|
||||
|
||||
vi.mock("./matrix/client.js", () => ({
|
||||
resolveMatrixAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("matrix directory live", () => {
|
||||
const cfg = { channels: { "matrix-js": {} } };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(resolveMatrixAuth).mockReset();
|
||||
vi.mocked(resolveMatrixAuth).mockResolvedValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "test-token",
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ results: [] }),
|
||||
text: async () => "",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("passes accountId to peer directory auth resolution", async () => {
|
||||
await listMatrixDirectoryPeersLive({
|
||||
cfg,
|
||||
accountId: "assistant",
|
||||
query: "alice",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(resolveMatrixAuth).toHaveBeenCalledWith({ cfg, accountId: "assistant" });
|
||||
});
|
||||
|
||||
it("passes accountId to group directory auth resolution", async () => {
|
||||
await listMatrixDirectoryGroupsLive({
|
||||
cfg,
|
||||
accountId: "assistant",
|
||||
query: "!room:example.org",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(resolveMatrixAuth).toHaveBeenCalledWith({ cfg, accountId: "assistant" });
|
||||
});
|
||||
|
||||
it("returns no peer results for empty query without resolving auth", async () => {
|
||||
const result = await listMatrixDirectoryPeersLive({
|
||||
cfg,
|
||||
query: " ",
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(resolveMatrixAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns no group results for empty query without resolving auth", async () => {
|
||||
const result = await listMatrixDirectoryGroupsLive({
|
||||
cfg,
|
||||
query: "",
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(resolveMatrixAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
208
extensions/matrix-js/src/directory-live.ts
Normal file
208
extensions/matrix-js/src/directory-live.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/matrix-js";
|
||||
import { resolveMatrixAuth } from "./matrix/client.js";
|
||||
|
||||
type MatrixUserResult = {
|
||||
user_id?: string;
|
||||
display_name?: string;
|
||||
};
|
||||
|
||||
type MatrixUserDirectoryResponse = {
|
||||
results?: MatrixUserResult[];
|
||||
};
|
||||
|
||||
type MatrixJoinedRoomsResponse = {
|
||||
joined_rooms?: string[];
|
||||
};
|
||||
|
||||
type MatrixRoomNameState = {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type MatrixAliasLookup = {
|
||||
room_id?: string;
|
||||
};
|
||||
|
||||
type MatrixDirectoryLiveParams = {
|
||||
cfg: unknown;
|
||||
accountId?: string | null;
|
||||
query?: string | null;
|
||||
limit?: number | null;
|
||||
};
|
||||
|
||||
type MatrixResolvedAuth = Awaited<ReturnType<typeof resolveMatrixAuth>>;
|
||||
|
||||
async function fetchMatrixJson<T>(params: {
|
||||
homeserver: string;
|
||||
path: string;
|
||||
accessToken: string;
|
||||
method?: "GET" | "POST";
|
||||
body?: unknown;
|
||||
}): Promise<T> {
|
||||
const res = await fetch(`${params.homeserver}${params.path}`, {
|
||||
method: params.method ?? "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: params.body ? JSON.stringify(params.body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Matrix API ${params.path} failed (${res.status}): ${text || "unknown error"}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
function normalizeQuery(value?: string | null): string {
|
||||
return value?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function resolveMatrixDirectoryLimit(limit?: number | null): number {
|
||||
return typeof limit === "number" && limit > 0 ? limit : 20;
|
||||
}
|
||||
|
||||
async function resolveMatrixDirectoryContext(
|
||||
params: MatrixDirectoryLiveParams,
|
||||
): Promise<{ query: string; auth: MatrixResolvedAuth } | null> {
|
||||
const query = normalizeQuery(params.query);
|
||||
if (!query) {
|
||||
return null;
|
||||
}
|
||||
const auth = await resolveMatrixAuth({ cfg: params.cfg as never, accountId: params.accountId });
|
||||
return { query, auth };
|
||||
}
|
||||
|
||||
function createGroupDirectoryEntry(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
handle?: string;
|
||||
}): ChannelDirectoryEntry {
|
||||
return {
|
||||
kind: "group",
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
handle: params.handle,
|
||||
} satisfies ChannelDirectoryEntry;
|
||||
}
|
||||
|
||||
export async function listMatrixDirectoryPeersLive(
|
||||
params: MatrixDirectoryLiveParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const context = await resolveMatrixDirectoryContext(params);
|
||||
if (!context) {
|
||||
return [];
|
||||
}
|
||||
const { query, auth } = context;
|
||||
const res = await fetchMatrixJson<MatrixUserDirectoryResponse>({
|
||||
homeserver: auth.homeserver,
|
||||
accessToken: auth.accessToken,
|
||||
path: "/_matrix/client/v3/user_directory/search",
|
||||
method: "POST",
|
||||
body: {
|
||||
search_term: query,
|
||||
limit: resolveMatrixDirectoryLimit(params.limit),
|
||||
},
|
||||
});
|
||||
const results = res.results ?? [];
|
||||
return results
|
||||
.map((entry) => {
|
||||
const userId = entry.user_id?.trim();
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "user",
|
||||
id: userId,
|
||||
name: entry.display_name?.trim() || undefined,
|
||||
handle: entry.display_name ? `@${entry.display_name.trim()}` : undefined,
|
||||
raw: entry,
|
||||
} satisfies ChannelDirectoryEntry;
|
||||
})
|
||||
.filter(Boolean) as ChannelDirectoryEntry[];
|
||||
}
|
||||
|
||||
async function resolveMatrixRoomAlias(
|
||||
homeserver: string,
|
||||
accessToken: string,
|
||||
alias: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetchMatrixJson<MatrixAliasLookup>({
|
||||
homeserver,
|
||||
accessToken,
|
||||
path: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`,
|
||||
});
|
||||
return res.room_id?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMatrixRoomName(
|
||||
homeserver: string,
|
||||
accessToken: string,
|
||||
roomId: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetchMatrixJson<MatrixRoomNameState>({
|
||||
homeserver,
|
||||
accessToken,
|
||||
path: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`,
|
||||
});
|
||||
return res.name?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMatrixDirectoryGroupsLive(
|
||||
params: MatrixDirectoryLiveParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const context = await resolveMatrixDirectoryContext(params);
|
||||
if (!context) {
|
||||
return [];
|
||||
}
|
||||
const { query, auth } = context;
|
||||
const limit = resolveMatrixDirectoryLimit(params.limit);
|
||||
|
||||
if (query.startsWith("#")) {
|
||||
const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query);
|
||||
if (!roomId) {
|
||||
return [];
|
||||
}
|
||||
return [createGroupDirectoryEntry({ id: roomId, name: query, handle: query })];
|
||||
}
|
||||
|
||||
if (query.startsWith("!")) {
|
||||
return [createGroupDirectoryEntry({ id: query, name: query })];
|
||||
}
|
||||
|
||||
const joined = await fetchMatrixJson<MatrixJoinedRoomsResponse>({
|
||||
homeserver: auth.homeserver,
|
||||
accessToken: auth.accessToken,
|
||||
path: "/_matrix/client/v3/joined_rooms",
|
||||
});
|
||||
const rooms = joined.joined_rooms ?? [];
|
||||
const results: ChannelDirectoryEntry[] = [];
|
||||
|
||||
for (const roomId of rooms) {
|
||||
const name = await fetchMatrixRoomName(auth.homeserver, auth.accessToken, roomId);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
if (!name.toLowerCase().includes(query)) {
|
||||
continue;
|
||||
}
|
||||
results.push({
|
||||
kind: "group",
|
||||
id: roomId,
|
||||
name,
|
||||
handle: `#${name}`,
|
||||
});
|
||||
if (results.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
52
extensions/matrix-js/src/group-mentions.ts
Normal file
52
extensions/matrix-js/src/group-mentions.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk/matrix-js";
|
||||
import { resolveMatrixAccountConfig } from "./matrix/accounts.js";
|
||||
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
function stripLeadingPrefixCaseInsensitive(value: string, prefix: string): string {
|
||||
return value.toLowerCase().startsWith(prefix.toLowerCase())
|
||||
? value.slice(prefix.length).trim()
|
||||
: value;
|
||||
}
|
||||
|
||||
function resolveMatrixRoomConfigForGroup(params: ChannelGroupContext) {
|
||||
const rawGroupId = params.groupId?.trim() ?? "";
|
||||
let roomId = rawGroupId;
|
||||
roomId = stripLeadingPrefixCaseInsensitive(roomId, "matrix:");
|
||||
roomId = stripLeadingPrefixCaseInsensitive(roomId, "channel:");
|
||||
roomId = stripLeadingPrefixCaseInsensitive(roomId, "room:");
|
||||
|
||||
const groupChannel = params.groupChannel?.trim() ?? "";
|
||||
const aliases = groupChannel ? [groupChannel] : [];
|
||||
const cfg = params.cfg as CoreConfig;
|
||||
const matrixConfig = resolveMatrixAccountConfig({ cfg, accountId: params.accountId });
|
||||
return resolveMatrixRoomConfig({
|
||||
rooms: matrixConfig.groups ?? matrixConfig.rooms,
|
||||
roomId,
|
||||
aliases,
|
||||
name: groupChannel || undefined,
|
||||
}).config;
|
||||
}
|
||||
|
||||
export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean {
|
||||
const resolved = resolveMatrixRoomConfigForGroup(params);
|
||||
if (resolved) {
|
||||
if (resolved.autoReply === true) {
|
||||
return false;
|
||||
}
|
||||
if (resolved.autoReply === false) {
|
||||
return true;
|
||||
}
|
||||
if (typeof resolved.requireMention === "boolean") {
|
||||
return resolved.requireMention;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveMatrixGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const resolved = resolveMatrixRoomConfigForGroup(params);
|
||||
return resolved?.tools;
|
||||
}
|
||||
37
extensions/matrix-js/src/matrix/account-config.ts
Normal file
37
extensions/matrix-js/src/matrix/account-config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js";
|
||||
|
||||
export function resolveMatrixBaseConfig(cfg: CoreConfig): MatrixConfig {
|
||||
return cfg.channels?.["matrix-js"] ?? {};
|
||||
}
|
||||
|
||||
export function resolveMatrixAccountsMap(
|
||||
cfg: CoreConfig,
|
||||
): Readonly<Record<string, MatrixAccountConfig>> {
|
||||
const accounts = resolveMatrixBaseConfig(cfg).accounts;
|
||||
if (!accounts || typeof accounts !== "object") {
|
||||
return {};
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
|
||||
export function findMatrixAccountConfig(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
): MatrixAccountConfig | undefined {
|
||||
const accounts = resolveMatrixAccountsMap(cfg);
|
||||
if (accounts[accountId] && typeof accounts[accountId] === "object") {
|
||||
return accounts[accountId];
|
||||
}
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
for (const key of Object.keys(accounts)) {
|
||||
if (normalizeAccountId(key) === normalized) {
|
||||
const candidate = accounts[key];
|
||||
if (candidate && typeof candidate === "object") {
|
||||
return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
82
extensions/matrix-js/src/matrix/accounts.test.ts
Normal file
82
extensions/matrix-js/src/matrix/accounts.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { resolveMatrixAccount } from "./accounts.js";
|
||||
|
||||
vi.mock("./credentials.js", () => ({
|
||||
loadMatrixCredentials: () => null,
|
||||
credentialsMatchConfig: () => false,
|
||||
}));
|
||||
|
||||
const envKeys = [
|
||||
"MATRIX_HOMESERVER",
|
||||
"MATRIX_USER_ID",
|
||||
"MATRIX_ACCESS_TOKEN",
|
||||
"MATRIX_PASSWORD",
|
||||
"MATRIX_DEVICE_NAME",
|
||||
];
|
||||
|
||||
describe("resolveMatrixAccount", () => {
|
||||
let prevEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
prevEnv = {};
|
||||
for (const key of envKeys) {
|
||||
prevEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of envKeys) {
|
||||
const value = prevEnv[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("treats access-token-only config as configured", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok-access",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMatrixAccount({ cfg });
|
||||
expect(account.configured).toBe(true);
|
||||
});
|
||||
|
||||
it("requires userId + password when no access token is set", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMatrixAccount({ cfg });
|
||||
expect(account.configured).toBe(false);
|
||||
});
|
||||
|
||||
it("marks password auth as configured when userId is present", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMatrixAccount({ cfg });
|
||||
expect(account.configured).toBe(true);
|
||||
});
|
||||
});
|
||||
127
extensions/matrix-js/src/matrix/accounts.ts
Normal file
127
extensions/matrix-js/src/matrix/accounts.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import type { CoreConfig, MatrixConfig } from "../types.js";
|
||||
import {
|
||||
findMatrixAccountConfig,
|
||||
resolveMatrixAccountsMap,
|
||||
resolveMatrixBaseConfig,
|
||||
} from "./account-config.js";
|
||||
import { resolveMatrixConfigForAccount } from "./client.js";
|
||||
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
|
||||
|
||||
/** Merge account config with top-level defaults, preserving nested objects. */
|
||||
function mergeAccountConfig(base: MatrixConfig, account: MatrixConfig): MatrixConfig {
|
||||
const merged = { ...base, ...account };
|
||||
// Deep-merge known nested objects so partial overrides inherit base fields
|
||||
for (const key of ["dm", "actions"] as const) {
|
||||
const b = base[key];
|
||||
const o = account[key];
|
||||
if (typeof b === "object" && b != null && typeof o === "object" && o != null) {
|
||||
(merged as Record<string, unknown>)[key] = { ...b, ...o };
|
||||
}
|
||||
}
|
||||
// Don't propagate the accounts map into the merged per-account config
|
||||
delete (merged as Record<string, unknown>).accounts;
|
||||
return merged;
|
||||
}
|
||||
|
||||
export type ResolvedMatrixAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
configured: boolean;
|
||||
homeserver?: string;
|
||||
userId?: string;
|
||||
config: MatrixConfig;
|
||||
};
|
||||
|
||||
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
|
||||
const accounts = resolveMatrixAccountsMap(cfg);
|
||||
if (Object.keys(accounts).length === 0) {
|
||||
return [];
|
||||
}
|
||||
// Normalize and de-duplicate keys so listing and resolution use the same semantics
|
||||
return [
|
||||
...new Set(
|
||||
Object.keys(accounts)
|
||||
.filter(Boolean)
|
||||
.map((id) => normalizeAccountId(id)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function listMatrixAccountIds(cfg: CoreConfig): string[] {
|
||||
const ids = listConfiguredAccountIds(cfg);
|
||||
if (ids.length === 0) {
|
||||
// Fall back to default if no accounts configured (legacy top-level config)
|
||||
return [DEFAULT_ACCOUNT_ID];
|
||||
}
|
||||
return ids.toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
|
||||
const ids = listMatrixAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function resolveAccountConfig(cfg: CoreConfig, accountId: string): MatrixConfig | undefined {
|
||||
return findMatrixAccountConfig(cfg, accountId);
|
||||
}
|
||||
|
||||
export function resolveMatrixAccount(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedMatrixAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const matrixBase = resolveMatrixBaseConfig(params.cfg);
|
||||
const base = resolveMatrixAccountConfig({ cfg: params.cfg, accountId });
|
||||
const enabled = base.enabled !== false && matrixBase.enabled !== false;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(params.cfg, accountId, process.env);
|
||||
const hasHomeserver = Boolean(resolved.homeserver);
|
||||
const hasUserId = Boolean(resolved.userId);
|
||||
const hasAccessToken = Boolean(resolved.accessToken);
|
||||
const hasPassword = Boolean(resolved.password);
|
||||
const hasPasswordAuth = hasUserId && hasPassword;
|
||||
const stored = loadMatrixCredentials(process.env, accountId);
|
||||
const hasStored =
|
||||
stored && resolved.homeserver
|
||||
? credentialsMatchConfig(stored, {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: resolved.userId || "",
|
||||
})
|
||||
: false;
|
||||
const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || Boolean(hasStored));
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: base.name?.trim() || undefined,
|
||||
configured,
|
||||
homeserver: resolved.homeserver || undefined,
|
||||
userId: resolved.userId || undefined,
|
||||
config: base,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMatrixAccountConfig(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
}): MatrixConfig {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const matrixBase = resolveMatrixBaseConfig(params.cfg);
|
||||
const accountConfig = resolveAccountConfig(params.cfg, accountId);
|
||||
if (!accountConfig) {
|
||||
return matrixBase;
|
||||
}
|
||||
// Merge account-specific config with top-level defaults so settings like
|
||||
// groupPolicy and blockStreaming inherit when not overridden.
|
||||
return mergeAccountConfig(matrixBase, accountConfig);
|
||||
}
|
||||
|
||||
export function listEnabledMatrixAccounts(cfg: CoreConfig): ResolvedMatrixAccount[] {
|
||||
return listMatrixAccountIds(cfg)
|
||||
.map((accountId) => resolveMatrixAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
35
extensions/matrix-js/src/matrix/actions.ts
Normal file
35
extensions/matrix-js/src/matrix/actions.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export type {
|
||||
MatrixActionClientOpts,
|
||||
MatrixMessageSummary,
|
||||
MatrixReactionSummary,
|
||||
} from "./actions/types.js";
|
||||
export {
|
||||
sendMatrixMessage,
|
||||
editMatrixMessage,
|
||||
deleteMatrixMessage,
|
||||
readMatrixMessages,
|
||||
} from "./actions/messages.js";
|
||||
export { listMatrixReactions, removeMatrixReactions } from "./actions/reactions.js";
|
||||
export { pinMatrixMessage, unpinMatrixMessage, listMatrixPins } from "./actions/pins.js";
|
||||
export { getMatrixMemberInfo, getMatrixRoomInfo } from "./actions/room.js";
|
||||
export { updateMatrixOwnProfile } from "./actions/profile.js";
|
||||
export {
|
||||
bootstrapMatrixVerification,
|
||||
acceptMatrixVerification,
|
||||
cancelMatrixVerification,
|
||||
confirmMatrixVerificationReciprocateQr,
|
||||
confirmMatrixVerificationSas,
|
||||
generateMatrixVerificationQr,
|
||||
getMatrixEncryptionStatus,
|
||||
getMatrixRoomKeyBackupStatus,
|
||||
getMatrixVerificationStatus,
|
||||
getMatrixVerificationSas,
|
||||
listMatrixVerifications,
|
||||
mismatchMatrixVerificationSas,
|
||||
requestMatrixVerification,
|
||||
restoreMatrixRoomKeyBackup,
|
||||
scanMatrixVerificationQr,
|
||||
startMatrixVerification,
|
||||
verifyMatrixRecoveryKey,
|
||||
} from "./actions/verification.js";
|
||||
export { reactMatrixMessage } from "./send.js";
|
||||
87
extensions/matrix-js/src/matrix/actions/client.test.ts
Normal file
87
extensions/matrix-js/src/matrix/actions/client.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
|
||||
const loadConfigMock = vi.fn(() => ({}));
|
||||
const getActiveMatrixClientMock = vi.fn();
|
||||
const createMatrixClientMock = vi.fn();
|
||||
const isBunRuntimeMock = vi.fn(() => false);
|
||||
const resolveMatrixAuthMock = vi.fn();
|
||||
|
||||
vi.mock("../../runtime.js", () => ({
|
||||
getMatrixRuntime: () => ({
|
||||
config: {
|
||||
loadConfig: loadConfigMock,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../active-client.js", () => ({
|
||||
getActiveMatrixClient: getActiveMatrixClientMock,
|
||||
}));
|
||||
|
||||
vi.mock("../client.js", () => ({
|
||||
createMatrixClient: createMatrixClientMock,
|
||||
isBunRuntime: () => isBunRuntimeMock(),
|
||||
resolveMatrixAuth: resolveMatrixAuthMock,
|
||||
}));
|
||||
|
||||
let resolveActionClient: typeof import("./client.js").resolveActionClient;
|
||||
|
||||
function createMockMatrixClient(): MatrixClient {
|
||||
return {
|
||||
prepareForOneOff: vi.fn(async () => undefined),
|
||||
} as unknown as MatrixClient;
|
||||
}
|
||||
|
||||
describe("resolveActionClient", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
getActiveMatrixClientMock.mockReturnValue(null);
|
||||
isBunRuntimeMock.mockReturnValue(false);
|
||||
resolveMatrixAuthMock.mockResolvedValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
password: undefined,
|
||||
deviceId: "DEVICE123",
|
||||
encryption: false,
|
||||
});
|
||||
createMatrixClientMock.mockResolvedValue(createMockMatrixClient());
|
||||
|
||||
({ resolveActionClient } = await import("./client.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("creates a one-off client even when OPENCLAW_GATEWAY_PORT is set", async () => {
|
||||
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799");
|
||||
|
||||
const result = await resolveActionClient({ accountId: "default" });
|
||||
|
||||
expect(getActiveMatrixClientMock).toHaveBeenCalledWith("default");
|
||||
expect(resolveMatrixAuthMock).toHaveBeenCalledTimes(1);
|
||||
expect(createMatrixClientMock).toHaveBeenCalledTimes(1);
|
||||
expect(createMatrixClientMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
autoBootstrapCrypto: false,
|
||||
}),
|
||||
);
|
||||
const oneOffClient = await createMatrixClientMock.mock.results[0]?.value;
|
||||
expect(oneOffClient.prepareForOneOff).toHaveBeenCalledTimes(1);
|
||||
expect(result.stopOnDone).toBe(true);
|
||||
});
|
||||
|
||||
it("reuses active monitor client when available", async () => {
|
||||
const activeClient = createMockMatrixClient();
|
||||
getActiveMatrixClientMock.mockReturnValue(activeClient);
|
||||
|
||||
const result = await resolveActionClient({ accountId: "default" });
|
||||
|
||||
expect(result).toEqual({ client: activeClient, stopOnDone: false });
|
||||
expect(resolveMatrixAuthMock).not.toHaveBeenCalled();
|
||||
expect(createMatrixClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
70
extensions/matrix-js/src/matrix/actions/client.ts
Normal file
70
extensions/matrix-js/src/matrix/actions/client.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { getActiveMatrixClient } from "../active-client.js";
|
||||
import { createMatrixClient, isBunRuntime, resolveMatrixAuth } from "../client.js";
|
||||
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
export function ensureNodeRuntime() {
|
||||
if (isBunRuntime()) {
|
||||
throw new Error("Matrix support requires Node (bun runtime not supported)");
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveActionClient(
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<MatrixActionClient> {
|
||||
ensureNodeRuntime();
|
||||
if (opts.client) {
|
||||
return { client: opts.client, stopOnDone: false };
|
||||
}
|
||||
const active = getActiveMatrixClient(opts.accountId);
|
||||
if (active) {
|
||||
return { client: active, stopOnDone: false };
|
||||
}
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
accountId: opts.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,
|
||||
accountId: opts.accountId,
|
||||
autoBootstrapCrypto: false,
|
||||
});
|
||||
await client.prepareForOneOff();
|
||||
return { client, stopOnDone: true };
|
||||
}
|
||||
|
||||
export type MatrixActionClientStopMode = "stop" | "persist";
|
||||
|
||||
export async function stopActionClient(
|
||||
resolved: MatrixActionClient,
|
||||
mode: MatrixActionClientStopMode = "stop",
|
||||
): Promise<void> {
|
||||
if (!resolved.stopOnDone) {
|
||||
return;
|
||||
}
|
||||
if (mode === "persist") {
|
||||
await resolved.client.stopAndPersist();
|
||||
return;
|
||||
}
|
||||
resolved.client.stop();
|
||||
}
|
||||
|
||||
export async function withResolvedActionClient<T>(
|
||||
opts: MatrixActionClientOpts,
|
||||
run: (client: MatrixActionClient["client"]) => Promise<T>,
|
||||
mode: MatrixActionClientStopMode = "stop",
|
||||
): Promise<T> {
|
||||
const resolved = await resolveActionClient(opts);
|
||||
try {
|
||||
return await run(resolved.client);
|
||||
} finally {
|
||||
await stopActionClient(resolved, mode);
|
||||
}
|
||||
}
|
||||
15
extensions/matrix-js/src/matrix/actions/limits.test.ts
Normal file
15
extensions/matrix-js/src/matrix/actions/limits.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveMatrixActionLimit } from "./limits.js";
|
||||
|
||||
describe("resolveMatrixActionLimit", () => {
|
||||
it("uses fallback for non-finite values", () => {
|
||||
expect(resolveMatrixActionLimit(undefined, 20)).toBe(20);
|
||||
expect(resolveMatrixActionLimit(Number.NaN, 20)).toBe(20);
|
||||
});
|
||||
|
||||
it("normalizes finite numbers to positive integers", () => {
|
||||
expect(resolveMatrixActionLimit(7.9, 20)).toBe(7);
|
||||
expect(resolveMatrixActionLimit(0, 20)).toBe(1);
|
||||
expect(resolveMatrixActionLimit(-3, 20)).toBe(1);
|
||||
});
|
||||
});
|
||||
6
extensions/matrix-js/src/matrix/actions/limits.ts
Normal file
6
extensions/matrix-js/src/matrix/actions/limits.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function resolveMatrixActionLimit(raw: unknown, fallback: number): number {
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(1, Math.floor(raw));
|
||||
}
|
||||
111
extensions/matrix-js/src/matrix/actions/messages.ts
Normal file
111
extensions/matrix-js/src/matrix/actions/messages.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js";
|
||||
import { withResolvedActionClient } from "./client.js";
|
||||
import { resolveMatrixActionLimit } from "./limits.js";
|
||||
import { summarizeMatrixRawEvent } from "./summary.js";
|
||||
import {
|
||||
EventType,
|
||||
MsgType,
|
||||
RelationType,
|
||||
type MatrixActionClientOpts,
|
||||
type MatrixMessageSummary,
|
||||
type MatrixRawEvent,
|
||||
type RoomMessageEventContent,
|
||||
} from "./types.js";
|
||||
|
||||
export async function sendMatrixMessage(
|
||||
to: string,
|
||||
content: string,
|
||||
opts: MatrixActionClientOpts & {
|
||||
mediaUrl?: string;
|
||||
replyToId?: string;
|
||||
threadId?: string;
|
||||
} = {},
|
||||
) {
|
||||
return await sendMessageMatrix(to, content, {
|
||||
mediaUrl: opts.mediaUrl,
|
||||
replyToId: opts.replyToId,
|
||||
threadId: opts.threadId,
|
||||
client: opts.client,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function editMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
content: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Matrix edit requires content");
|
||||
}
|
||||
return await withResolvedActionClient(opts, async (client) => {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const newContent = {
|
||||
msgtype: MsgType.Text,
|
||||
body: trimmed,
|
||||
} satisfies RoomMessageEventContent;
|
||||
const payload: RoomMessageEventContent = {
|
||||
msgtype: MsgType.Text,
|
||||
body: `* ${trimmed}`,
|
||||
"m.new_content": newContent,
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Replace,
|
||||
event_id: messageId,
|
||||
},
|
||||
};
|
||||
const eventId = await client.sendMessage(resolvedRoom, payload);
|
||||
return { eventId: eventId ?? null };
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts & { reason?: string } = {},
|
||||
) {
|
||||
await withResolvedActionClient(opts, async (client) => {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
await client.redactEvent(resolvedRoom, messageId, opts.reason);
|
||||
});
|
||||
}
|
||||
|
||||
export async function readMatrixMessages(
|
||||
roomId: string,
|
||||
opts: MatrixActionClientOpts & {
|
||||
limit?: number;
|
||||
before?: string;
|
||||
after?: string;
|
||||
} = {},
|
||||
): Promise<{
|
||||
messages: MatrixMessageSummary[];
|
||||
nextBatch?: string | null;
|
||||
prevBatch?: string | null;
|
||||
}> {
|
||||
return await withResolvedActionClient(opts, async (client) => {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
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.
|
||||
const res = (await client.doRequest(
|
||||
"GET",
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,
|
||||
{
|
||||
dir,
|
||||
limit,
|
||||
from: token,
|
||||
},
|
||||
)) as { chunk: MatrixRawEvent[]; start?: string; end?: string };
|
||||
const messages = res.chunk
|
||||
.filter((event) => event.type === EventType.RoomMessage)
|
||||
.filter((event) => !event.unsigned?.redacted_because)
|
||||
.map(summarizeMatrixRawEvent);
|
||||
return {
|
||||
messages,
|
||||
nextBatch: res.end ?? null,
|
||||
prevBatch: res.start ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
74
extensions/matrix-js/src/matrix/actions/pins.test.ts
Normal file
74
extensions/matrix-js/src/matrix/actions/pins.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { listMatrixPins, pinMatrixMessage, unpinMatrixMessage } from "./pins.js";
|
||||
|
||||
function createPinsClient(seedPinned: string[], knownBodies: Record<string, string> = {}) {
|
||||
let pinned = [...seedPinned];
|
||||
const getRoomStateEvent = vi.fn(async () => ({ pinned: [...pinned] }));
|
||||
const sendStateEvent = vi.fn(
|
||||
async (_roomId: string, _type: string, _key: string, payload: any) => {
|
||||
pinned = [...payload.pinned];
|
||||
},
|
||||
);
|
||||
const getEvent = vi.fn(async (_roomId: string, eventId: string) => {
|
||||
const body = knownBodies[eventId];
|
||||
if (!body) {
|
||||
throw new Error("missing");
|
||||
}
|
||||
return {
|
||||
event_id: eventId,
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 123,
|
||||
content: { msgtype: "m.text", body },
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
client: {
|
||||
getRoomStateEvent,
|
||||
sendStateEvent,
|
||||
getEvent,
|
||||
stop: vi.fn(),
|
||||
} as unknown as MatrixClient,
|
||||
getPinned: () => pinned,
|
||||
sendStateEvent,
|
||||
};
|
||||
}
|
||||
|
||||
describe("matrix pins actions", () => {
|
||||
it("pins a message once even when asked twice", async () => {
|
||||
const { client, getPinned, sendStateEvent } = createPinsClient(["$a"]);
|
||||
|
||||
const first = await pinMatrixMessage("!room:example.org", "$b", { client });
|
||||
const second = await pinMatrixMessage("!room:example.org", "$b", { client });
|
||||
|
||||
expect(first.pinned).toEqual(["$a", "$b"]);
|
||||
expect(second.pinned).toEqual(["$a", "$b"]);
|
||||
expect(getPinned()).toEqual(["$a", "$b"]);
|
||||
expect(sendStateEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("unpinds only the selected message id", async () => {
|
||||
const { client, getPinned } = createPinsClient(["$a", "$b", "$c"]);
|
||||
|
||||
const result = await unpinMatrixMessage("!room:example.org", "$b", { client });
|
||||
|
||||
expect(result.pinned).toEqual(["$a", "$c"]);
|
||||
expect(getPinned()).toEqual(["$a", "$c"]);
|
||||
});
|
||||
|
||||
it("lists pinned ids and summarizes only resolvable events", async () => {
|
||||
const { client } = createPinsClient(["$a", "$missing"], { $a: "hello" });
|
||||
|
||||
const result = await listMatrixPins("!room:example.org", { client });
|
||||
|
||||
expect(result.pinned).toEqual(["$a", "$missing"]);
|
||||
expect(result.events).toEqual([
|
||||
expect.objectContaining({
|
||||
eventId: "$a",
|
||||
body: "hello",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
79
extensions/matrix-js/src/matrix/actions/pins.ts
Normal file
79
extensions/matrix-js/src/matrix/actions/pins.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { resolveMatrixRoomId } from "../send.js";
|
||||
import { withResolvedActionClient } from "./client.js";
|
||||
import { fetchEventSummary, readPinnedEvents } from "./summary.js";
|
||||
import {
|
||||
EventType,
|
||||
type MatrixActionClientOpts,
|
||||
type MatrixActionClient,
|
||||
type MatrixMessageSummary,
|
||||
type RoomPinnedEventsEventContent,
|
||||
} from "./types.js";
|
||||
|
||||
type ActionClient = MatrixActionClient["client"];
|
||||
|
||||
async function withResolvedPinRoom<T>(
|
||||
roomId: string,
|
||||
opts: MatrixActionClientOpts,
|
||||
run: (client: ActionClient, resolvedRoom: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return await withResolvedActionClient(opts, async (client) => {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
return await run(client, resolvedRoom);
|
||||
});
|
||||
}
|
||||
|
||||
async function updateMatrixPins(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts,
|
||||
update: (current: string[]) => string[],
|
||||
): Promise<{ pinned: string[] }> {
|
||||
return await withResolvedPinRoom(roomId, opts, async (client, resolvedRoom) => {
|
||||
const current = await readPinnedEvents(client, resolvedRoom);
|
||||
const next = update(current);
|
||||
const payload: RoomPinnedEventsEventContent = { pinned: next };
|
||||
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
|
||||
return { pinned: next };
|
||||
});
|
||||
}
|
||||
|
||||
export async function pinMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<{ pinned: string[] }> {
|
||||
return await updateMatrixPins(roomId, messageId, opts, (current) =>
|
||||
current.includes(messageId) ? current : [...current, messageId],
|
||||
);
|
||||
}
|
||||
|
||||
export async function unpinMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<{ pinned: string[] }> {
|
||||
return await updateMatrixPins(roomId, messageId, opts, (current) =>
|
||||
current.filter((id) => id !== messageId),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listMatrixPins(
|
||||
roomId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> {
|
||||
return await withResolvedPinRoom(roomId, opts, async (client, resolvedRoom) => {
|
||||
const pinned = await readPinnedEvents(client, resolvedRoom);
|
||||
const events = (
|
||||
await Promise.all(
|
||||
pinned.map(async (eventId) => {
|
||||
try {
|
||||
return await fetchEventSummary(client, resolvedRoom, eventId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter((event): event is MatrixMessageSummary => Boolean(event));
|
||||
return { pinned, events };
|
||||
});
|
||||
}
|
||||
29
extensions/matrix-js/src/matrix/actions/profile.ts
Normal file
29
extensions/matrix-js/src/matrix/actions/profile.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { syncMatrixOwnProfile, type MatrixProfileSyncResult } from "../profile.js";
|
||||
import { withResolvedActionClient } from "./client.js";
|
||||
import type { MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
export async function updateMatrixOwnProfile(
|
||||
opts: MatrixActionClientOpts & {
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
} = {},
|
||||
): Promise<MatrixProfileSyncResult> {
|
||||
const displayName = opts.displayName?.trim();
|
||||
const avatarUrl = opts.avatarUrl?.trim();
|
||||
const runtime = getMatrixRuntime();
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) => {
|
||||
const userId = await client.getUserId();
|
||||
return await syncMatrixOwnProfile({
|
||||
client,
|
||||
userId,
|
||||
displayName: displayName || undefined,
|
||||
avatarUrl: avatarUrl || undefined,
|
||||
loadAvatarFromUrl: async (url, maxBytes) => await runtime.media.loadWebMedia(url, maxBytes),
|
||||
});
|
||||
},
|
||||
"persist",
|
||||
);
|
||||
}
|
||||
109
extensions/matrix-js/src/matrix/actions/reactions.test.ts
Normal file
109
extensions/matrix-js/src/matrix/actions/reactions.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { listMatrixReactions, removeMatrixReactions } from "./reactions.js";
|
||||
|
||||
function createReactionsClient(params: {
|
||||
chunk: Array<{
|
||||
event_id?: string;
|
||||
sender?: string;
|
||||
key?: string;
|
||||
}>;
|
||||
userId?: string | null;
|
||||
}) {
|
||||
const doRequest = vi.fn(async (_method: string, _path: string, _query: any) => ({
|
||||
chunk: params.chunk.map((item) => ({
|
||||
event_id: item.event_id ?? "",
|
||||
sender: item.sender ?? "",
|
||||
content: item.key
|
||||
? {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.annotation",
|
||||
event_id: "$target",
|
||||
key: item.key,
|
||||
},
|
||||
}
|
||||
: {},
|
||||
})),
|
||||
}));
|
||||
const getUserId = vi.fn(async () => params.userId ?? null);
|
||||
const redactEvent = vi.fn(async () => undefined);
|
||||
|
||||
return {
|
||||
client: {
|
||||
doRequest,
|
||||
getUserId,
|
||||
redactEvent,
|
||||
stop: vi.fn(),
|
||||
} as unknown as MatrixClient,
|
||||
doRequest,
|
||||
redactEvent,
|
||||
};
|
||||
}
|
||||
|
||||
describe("matrix reaction actions", () => {
|
||||
it("aggregates reactions by key and unique sender", async () => {
|
||||
const { client, doRequest } = createReactionsClient({
|
||||
chunk: [
|
||||
{ event_id: "$1", sender: "@alice:example.org", key: "👍" },
|
||||
{ event_id: "$2", sender: "@bob:example.org", key: "👍" },
|
||||
{ event_id: "$3", sender: "@alice:example.org", key: "👎" },
|
||||
{ event_id: "$4", sender: "@bot:example.org" },
|
||||
],
|
||||
userId: "@bot:example.org",
|
||||
});
|
||||
|
||||
const result = await listMatrixReactions("!room:example.org", "$msg", { client, limit: 2.9 });
|
||||
|
||||
expect(doRequest).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
expect.stringContaining("/rooms/!room%3Aexample.org/relations/%24msg/"),
|
||||
expect.objectContaining({ limit: 2 }),
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: "👍",
|
||||
count: 2,
|
||||
users: expect.arrayContaining(["@alice:example.org", "@bob:example.org"]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: "👎",
|
||||
count: 1,
|
||||
users: ["@alice:example.org"],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes only current-user reactions matching emoji filter", async () => {
|
||||
const { client, redactEvent } = createReactionsClient({
|
||||
chunk: [
|
||||
{ event_id: "$1", sender: "@me:example.org", key: "👍" },
|
||||
{ event_id: "$2", sender: "@me:example.org", key: "👎" },
|
||||
{ event_id: "$3", sender: "@other:example.org", key: "👍" },
|
||||
],
|
||||
userId: "@me:example.org",
|
||||
});
|
||||
|
||||
const result = await removeMatrixReactions("!room:example.org", "$msg", {
|
||||
client,
|
||||
emoji: "👍",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ removed: 1 });
|
||||
expect(redactEvent).toHaveBeenCalledTimes(1);
|
||||
expect(redactEvent).toHaveBeenCalledWith("!room:example.org", "$1");
|
||||
});
|
||||
|
||||
it("returns removed=0 when current user id is unavailable", async () => {
|
||||
const { client, redactEvent } = createReactionsClient({
|
||||
chunk: [{ event_id: "$1", sender: "@me:example.org", key: "👍" }],
|
||||
userId: null,
|
||||
});
|
||||
|
||||
const result = await removeMatrixReactions("!room:example.org", "$msg", { client });
|
||||
|
||||
expect(result).toEqual({ removed: 0 });
|
||||
expect(redactEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
86
extensions/matrix-js/src/matrix/actions/reactions.ts
Normal file
86
extensions/matrix-js/src/matrix/actions/reactions.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { resolveMatrixRoomId } from "../send.js";
|
||||
import { withResolvedActionClient } from "./client.js";
|
||||
import {
|
||||
EventType,
|
||||
RelationType,
|
||||
type MatrixActionClientOpts,
|
||||
type MatrixRawEvent,
|
||||
type MatrixReactionSummary,
|
||||
type ReactionEventContent,
|
||||
} from "./types.js";
|
||||
|
||||
export async function listMatrixReactions(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts & { limit?: number } = {},
|
||||
): Promise<MatrixReactionSummary[]> {
|
||||
return await withResolvedActionClient(opts, async (client) => {
|
||||
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 summaries = new Map<string, MatrixReactionSummary>();
|
||||
for (const event of res.chunk) {
|
||||
const content = event.content as ReactionEventContent;
|
||||
const key = content["m.relates_to"]?.key;
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const sender = event.sender ?? "";
|
||||
const entry: MatrixReactionSummary = summaries.get(key) ?? {
|
||||
key,
|
||||
count: 0,
|
||||
users: [],
|
||||
};
|
||||
entry.count += 1;
|
||||
if (sender && !entry.users.includes(sender)) {
|
||||
entry.users.push(sender);
|
||||
}
|
||||
summaries.set(key, entry);
|
||||
}
|
||||
return Array.from(summaries.values());
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeMatrixReactions(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts & { emoji?: string } = {},
|
||||
): Promise<{ removed: number }> {
|
||||
return await withResolvedActionClient(opts, async (client) => {
|
||||
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 userId = await client.getUserId();
|
||||
if (!userId) {
|
||||
return { removed: 0 };
|
||||
}
|
||||
const targetEmoji = opts.emoji?.trim();
|
||||
const toRemove = res.chunk
|
||||
.filter((event) => event.sender === userId)
|
||||
.filter((event) => {
|
||||
if (!targetEmoji) {
|
||||
return true;
|
||||
}
|
||||
const content = event.content as ReactionEventContent;
|
||||
return content["m.relates_to"]?.key === targetEmoji;
|
||||
})
|
||||
.map((event) => event.event_id)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
if (toRemove.length === 0) {
|
||||
return { removed: 0 };
|
||||
}
|
||||
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
|
||||
return { removed: toRemove.length };
|
||||
});
|
||||
}
|
||||
72
extensions/matrix-js/src/matrix/actions/room.ts
Normal file
72
extensions/matrix-js/src/matrix/actions/room.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { resolveMatrixRoomId } from "../send.js";
|
||||
import { withResolvedActionClient } from "./client.js";
|
||||
import { EventType, type MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
export async function getMatrixMemberInfo(
|
||||
userId: string,
|
||||
opts: MatrixActionClientOpts & { roomId?: string } = {},
|
||||
) {
|
||||
return await withResolvedActionClient(opts, async (client) => {
|
||||
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
|
||||
const profile = await client.getUserProfile(userId);
|
||||
// Membership and power levels are not included in profile calls; fetch state separately if needed.
|
||||
return {
|
||||
userId,
|
||||
profile: {
|
||||
displayName: profile?.displayname ?? null,
|
||||
avatarUrl: profile?.avatar_url ?? null,
|
||||
},
|
||||
membership: null, // Would need separate room state query
|
||||
powerLevel: null, // Would need separate power levels state query
|
||||
displayName: profile?.displayname ?? null,
|
||||
roomId: roomId ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) {
|
||||
return await withResolvedActionClient(opts, async (client) => {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
let name: string | null = null;
|
||||
let topic: string | null = null;
|
||||
let canonicalAlias: string | null = null;
|
||||
let memberCount: number | null = null;
|
||||
|
||||
try {
|
||||
const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", "");
|
||||
name = typeof nameState?.name === "string" ? nameState.name : null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, "");
|
||||
topic = typeof topicState?.topic === "string" ? topicState.topic : null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const aliasState = await client.getRoomStateEvent(resolvedRoom, "m.room.canonical_alias", "");
|
||||
canonicalAlias = typeof aliasState?.alias === "string" ? aliasState.alias : null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const members = await client.getJoinedRoomMembers(resolvedRoom);
|
||||
memberCount = members.length;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return {
|
||||
roomId: resolvedRoom,
|
||||
name,
|
||||
topic,
|
||||
canonicalAlias,
|
||||
altAliases: [], // Would need separate query
|
||||
memberCount,
|
||||
};
|
||||
});
|
||||
}
|
||||
75
extensions/matrix-js/src/matrix/actions/summary.ts
Normal file
75
extensions/matrix-js/src/matrix/actions/summary.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import {
|
||||
EventType,
|
||||
type MatrixMessageSummary,
|
||||
type MatrixRawEvent,
|
||||
type RoomMessageEventContent,
|
||||
type RoomPinnedEventsEventContent,
|
||||
} from "./types.js";
|
||||
|
||||
export function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSummary {
|
||||
const content = event.content as RoomMessageEventContent;
|
||||
const relates = content["m.relates_to"];
|
||||
let relType: string | undefined;
|
||||
let eventId: string | undefined;
|
||||
if (relates) {
|
||||
if ("rel_type" in relates) {
|
||||
relType = relates.rel_type;
|
||||
eventId = relates.event_id;
|
||||
} else if ("m.in_reply_to" in relates) {
|
||||
eventId = relates["m.in_reply_to"]?.event_id;
|
||||
}
|
||||
}
|
||||
const relatesTo =
|
||||
relType || eventId
|
||||
? {
|
||||
relType,
|
||||
eventId,
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
eventId: event.event_id,
|
||||
sender: event.sender,
|
||||
body: content.body,
|
||||
msgtype: content.msgtype,
|
||||
timestamp: event.origin_server_ts,
|
||||
relatesTo,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readPinnedEvents(client: MatrixClient, roomId: string): Promise<string[]> {
|
||||
try {
|
||||
const content = (await client.getRoomStateEvent(
|
||||
roomId,
|
||||
EventType.RoomPinnedEvents,
|
||||
"",
|
||||
)) as RoomPinnedEventsEventContent;
|
||||
const pinned = content.pinned;
|
||||
return pinned.filter((id) => id.trim().length > 0);
|
||||
} catch (err: unknown) {
|
||||
const errObj = err as { statusCode?: number; body?: { errcode?: string } };
|
||||
const httpStatus = errObj.statusCode;
|
||||
const errcode = errObj.body?.errcode;
|
||||
if (httpStatus === 404 || errcode === "M_NOT_FOUND") {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchEventSummary(
|
||||
client: MatrixClient,
|
||||
roomId: string,
|
||||
eventId: string,
|
||||
): Promise<MatrixMessageSummary | null> {
|
||||
try {
|
||||
const raw = (await client.getEvent(roomId, eventId)) as unknown as MatrixRawEvent;
|
||||
if (raw.unsigned?.redacted_because) {
|
||||
return null;
|
||||
}
|
||||
return summarizeMatrixRawEvent(raw);
|
||||
} catch {
|
||||
// Event not found, redacted, or inaccessible - return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
75
extensions/matrix-js/src/matrix/actions/types.ts
Normal file
75
extensions/matrix-js/src/matrix/actions/types.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { MatrixClient, MessageEventContent } from "../sdk.js";
|
||||
export type { MatrixRawEvent } from "../sdk.js";
|
||||
|
||||
export const MsgType = {
|
||||
Text: "m.text",
|
||||
} as const;
|
||||
|
||||
export const RelationType = {
|
||||
Replace: "m.replace",
|
||||
Annotation: "m.annotation",
|
||||
} as const;
|
||||
|
||||
export const EventType = {
|
||||
RoomMessage: "m.room.message",
|
||||
RoomPinnedEvents: "m.room.pinned_events",
|
||||
RoomTopic: "m.room.topic",
|
||||
Reaction: "m.reaction",
|
||||
} as const;
|
||||
|
||||
export type RoomMessageEventContent = MessageEventContent & {
|
||||
msgtype: string;
|
||||
body: string;
|
||||
"m.new_content"?: RoomMessageEventContent;
|
||||
"m.relates_to"?: {
|
||||
rel_type?: string;
|
||||
event_id?: string;
|
||||
"m.in_reply_to"?: { event_id?: string };
|
||||
};
|
||||
};
|
||||
|
||||
export type ReactionEventContent = {
|
||||
"m.relates_to": {
|
||||
rel_type: string;
|
||||
event_id: string;
|
||||
key: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type RoomPinnedEventsEventContent = {
|
||||
pinned: string[];
|
||||
};
|
||||
|
||||
export type RoomTopicEventContent = {
|
||||
topic?: string;
|
||||
};
|
||||
|
||||
export type MatrixActionClientOpts = {
|
||||
client?: MatrixClient;
|
||||
timeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
};
|
||||
|
||||
export type MatrixMessageSummary = {
|
||||
eventId?: string;
|
||||
sender?: string;
|
||||
body?: string;
|
||||
msgtype?: string;
|
||||
timestamp?: number;
|
||||
relatesTo?: {
|
||||
relType?: string;
|
||||
eventId?: string;
|
||||
key?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MatrixReactionSummary = {
|
||||
key: string;
|
||||
count: number;
|
||||
users: string[];
|
||||
};
|
||||
|
||||
export type MatrixActionClient = {
|
||||
client: MatrixClient;
|
||||
stopOnDone: boolean;
|
||||
};
|
||||
285
extensions/matrix-js/src/matrix/actions/verification.ts
Normal file
285
extensions/matrix-js/src/matrix/actions/verification.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { withResolvedActionClient } from "./client.js";
|
||||
import type { MatrixActionClientOpts } from "./types.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-js.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 = {}) {
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) => {
|
||||
const crypto = requireCrypto(client);
|
||||
return await crypto.listVerifications();
|
||||
},
|
||||
"persist",
|
||||
);
|
||||
}
|
||||
|
||||
export async function requestMatrixVerification(
|
||||
params: MatrixActionClientOpts & {
|
||||
ownUser?: boolean;
|
||||
userId?: string;
|
||||
deviceId?: string;
|
||||
roomId?: string;
|
||||
} = {},
|
||||
) {
|
||||
return await withResolvedActionClient(
|
||||
params,
|
||||
async (client) => {
|
||||
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,
|
||||
});
|
||||
},
|
||||
"persist",
|
||||
);
|
||||
}
|
||||
|
||||
export async function acceptMatrixVerification(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) => {
|
||||
const crypto = requireCrypto(client);
|
||||
return await crypto.acceptVerification(resolveVerificationId(requestId));
|
||||
},
|
||||
"persist",
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelMatrixVerification(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts & { reason?: string; code?: string } = {},
|
||||
) {
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) => {
|
||||
const crypto = requireCrypto(client);
|
||||
return await crypto.cancelVerification(resolveVerificationId(requestId), {
|
||||
reason: opts.reason?.trim() || undefined,
|
||||
code: opts.code?.trim() || undefined,
|
||||
});
|
||||
},
|
||||
"persist",
|
||||
);
|
||||
}
|
||||
|
||||
export async function startMatrixVerification(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts & { method?: "sas" } = {},
|
||||
) {
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) => {
|
||||
const crypto = requireCrypto(client);
|
||||
return await crypto.startVerification(resolveVerificationId(requestId), opts.method ?? "sas");
|
||||
},
|
||||
"persist",
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMatrixVerificationQr(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) => {
|
||||
const crypto = requireCrypto(client);
|
||||
return await crypto.generateVerificationQr(resolveVerificationId(requestId));
|
||||
},
|
||||
"persist",
|
||||
);
|
||||
}
|
||||
|
||||
export async function scanMatrixVerificationQr(
|
||||
requestId: string,
|
||||
qrDataBase64: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) => {
|
||||
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);
|
||||
},
|
||||
"persist",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getMatrixVerificationSas(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) => {
|
||||
const crypto = requireCrypto(client);
|
||||
return await crypto.getVerificationSas(resolveVerificationId(requestId));
|
||||
},
|
||||
"persist",
|
||||
);
|
||||
}
|
||||
|
||||
export async function confirmMatrixVerificationSas(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) => {
|
||||
const crypto = requireCrypto(client);
|
||||
return await crypto.confirmVerificationSas(resolveVerificationId(requestId));
|
||||
},
|
||||
"persist",
|
||||
);
|
||||
}
|
||||
|
||||
export async function mismatchMatrixVerificationSas(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) => {
|
||||
const crypto = requireCrypto(client);
|
||||
return await crypto.mismatchVerificationSas(resolveVerificationId(requestId));
|
||||
},
|
||||
"persist",
|
||||
);
|
||||
}
|
||||
|
||||
export async function confirmMatrixVerificationReciprocateQr(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) => {
|
||||
const crypto = requireCrypto(client);
|
||||
return await crypto.confirmVerificationReciprocateQr(resolveVerificationId(requestId));
|
||||
},
|
||||
"persist",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getMatrixEncryptionStatus(
|
||||
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {},
|
||||
) {
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) => {
|
||||
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,
|
||||
};
|
||||
},
|
||||
"persist",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getMatrixVerificationStatus(
|
||||
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {},
|
||||
) {
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) => {
|
||||
const status = await client.getOwnDeviceVerificationStatus();
|
||||
const payload = {
|
||||
...status,
|
||||
pendingVerifications: client.crypto ? (await client.crypto.listVerifications()).length : 0,
|
||||
};
|
||||
if (!opts.includeRecoveryKey) {
|
||||
return payload;
|
||||
}
|
||||
const recoveryKey = client.crypto ? await client.crypto.getRecoveryKey() : null;
|
||||
return {
|
||||
...payload,
|
||||
recoveryKey: recoveryKey?.encodedPrivateKey ?? null,
|
||||
};
|
||||
},
|
||||
"persist",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getMatrixRoomKeyBackupStatus(opts: MatrixActionClientOpts = {}) {
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) => await client.getRoomKeyBackupStatus(),
|
||||
"persist",
|
||||
);
|
||||
}
|
||||
|
||||
export async function verifyMatrixRecoveryKey(
|
||||
recoveryKey: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) => await client.verifyWithRecoveryKey(recoveryKey),
|
||||
"persist",
|
||||
);
|
||||
}
|
||||
|
||||
export async function restoreMatrixRoomKeyBackup(
|
||||
opts: MatrixActionClientOpts & {
|
||||
recoveryKey?: string;
|
||||
} = {},
|
||||
) {
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) =>
|
||||
await client.restoreRoomKeyBackup({
|
||||
recoveryKey: opts.recoveryKey?.trim() || undefined,
|
||||
}),
|
||||
"persist",
|
||||
);
|
||||
}
|
||||
|
||||
export async function bootstrapMatrixVerification(
|
||||
opts: MatrixActionClientOpts & {
|
||||
recoveryKey?: string;
|
||||
forceResetCrossSigning?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) =>
|
||||
await client.bootstrapOwnDeviceVerification({
|
||||
recoveryKey: opts.recoveryKey?.trim() || undefined,
|
||||
forceResetCrossSigning: opts.forceResetCrossSigning === true,
|
||||
}),
|
||||
"persist",
|
||||
);
|
||||
}
|
||||
26
extensions/matrix-js/src/matrix/active-client.ts
Normal file
26
extensions/matrix-js/src/matrix/active-client.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
|
||||
const activeClients = new Map<string, MatrixClient>();
|
||||
|
||||
function resolveAccountKey(accountId?: string | null): string {
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
return normalized || DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
export function setActiveMatrixClient(
|
||||
client: MatrixClient | null,
|
||||
accountId?: string | null,
|
||||
): void {
|
||||
const key = resolveAccountKey(accountId);
|
||||
if (!client) {
|
||||
activeClients.delete(key);
|
||||
return;
|
||||
}
|
||||
activeClients.set(key, client);
|
||||
}
|
||||
|
||||
export function getActiveMatrixClient(accountId?: string | null): MatrixClient | null {
|
||||
const key = resolveAccountKey(accountId);
|
||||
return activeClients.get(key) ?? null;
|
||||
}
|
||||
39
extensions/matrix-js/src/matrix/client-bootstrap.ts
Normal file
39
extensions/matrix-js/src/matrix/client-bootstrap.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createMatrixClient } from "./client.js";
|
||||
|
||||
type MatrixClientBootstrapAuth = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
encryption?: boolean;
|
||||
};
|
||||
|
||||
type MatrixCryptoPrepare = {
|
||||
prepare: (rooms?: string[]) => Promise<void>;
|
||||
};
|
||||
|
||||
type MatrixBootstrapClient = Awaited<ReturnType<typeof createMatrixClient>>;
|
||||
|
||||
export async function createPreparedMatrixClient(opts: {
|
||||
auth: MatrixClientBootstrapAuth;
|
||||
timeoutMs?: number;
|
||||
accountId?: string;
|
||||
}): Promise<MatrixBootstrapClient> {
|
||||
const client = await createMatrixClient({
|
||||
homeserver: opts.auth.homeserver,
|
||||
userId: opts.auth.userId,
|
||||
accessToken: opts.auth.accessToken,
|
||||
encryption: opts.auth.encryption,
|
||||
localTimeoutMs: opts.timeoutMs,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
if (opts.auth.encryption && client.crypto) {
|
||||
try {
|
||||
const joinedRooms = await client.getJoinedRooms();
|
||||
await (client.crypto as MatrixCryptoPrepare).prepare(joinedRooms);
|
||||
} catch {
|
||||
// Ignore crypto prep failures for one-off requests.
|
||||
}
|
||||
}
|
||||
await client.start();
|
||||
return client;
|
||||
}
|
||||
314
extensions/matrix-js/src/matrix/client.test.ts
Normal file
314
extensions/matrix-js/src/matrix/client.test.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { resolveMatrixAuth, resolveMatrixConfig, resolveMatrixConfigForAccount } from "./client.js";
|
||||
import * as credentialsModule from "./credentials.js";
|
||||
import * as sdkModule from "./sdk.js";
|
||||
|
||||
const saveMatrixCredentialsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./credentials.js", () => ({
|
||||
loadMatrixCredentials: vi.fn(() => null),
|
||||
saveMatrixCredentials: saveMatrixCredentialsMock,
|
||||
credentialsMatchConfig: vi.fn(() => false),
|
||||
touchMatrixCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("resolveMatrixConfig", () => {
|
||||
it("prefers config over env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
homeserver: "https://cfg.example.org",
|
||||
userId: "@cfg:example.org",
|
||||
accessToken: "cfg-token",
|
||||
password: "cfg-pass",
|
||||
deviceName: "CfgDevice",
|
||||
initialSyncLimit: 5,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://env.example.org",
|
||||
MATRIX_USER_ID: "@env:example.org",
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
MATRIX_DEVICE_NAME: "EnvDevice",
|
||||
} as NodeJS.ProcessEnv;
|
||||
const resolved = resolveMatrixConfig(cfg, env);
|
||||
expect(resolved).toEqual({
|
||||
homeserver: "https://cfg.example.org",
|
||||
userId: "@cfg:example.org",
|
||||
accessToken: "cfg-token",
|
||||
password: "cfg-pass",
|
||||
deviceId: undefined,
|
||||
deviceName: "CfgDevice",
|
||||
initialSyncLimit: 5,
|
||||
encryption: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses env when config is missing", () => {
|
||||
const cfg = {} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://env.example.org",
|
||||
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);
|
||||
expect(resolved.homeserver).toBe("https://env.example.org");
|
||||
expect(resolved.userId).toBe("@env:example.org");
|
||||
expect(resolved.accessToken).toBe("env-token");
|
||||
expect(resolved.password).toBe("env-pass");
|
||||
expect(resolved.deviceId).toBe("ENVDEVICE");
|
||||
expect(resolved.deviceName).toBe("EnvDevice");
|
||||
expect(resolved.initialSyncLimit).toBeUndefined();
|
||||
expect(resolved.encryption).toBe(false);
|
||||
});
|
||||
|
||||
it("uses account-scoped env vars for non-default accounts before global env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
homeserver: "https://base.example.org",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://global.example.org",
|
||||
MATRIX_ACCESS_TOKEN: "global-token",
|
||||
MATRIX_OPS_HOMESERVER: "https://ops.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
MATRIX_OPS_DEVICE_NAME: "Ops Device",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.homeserver).toBe("https://ops.example.org");
|
||||
expect(resolved.accessToken).toBe("ops-token");
|
||||
expect(resolved.deviceName).toBe("Ops Device");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMatrixAuth", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
saveMatrixCredentialsMock.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-js": {
|
||||
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",
|
||||
}),
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces password login errors when account credentials are invalid", async () => {
|
||||
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest");
|
||||
doRequestSpy.mockRejectedValueOnce(new Error("Invalid username or password"));
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
await expect(
|
||||
resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
}),
|
||||
).rejects.toThrow("Invalid username or password");
|
||||
|
||||
expect(doRequestSpy).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/_matrix/client/v3/login",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
type: "m.login.password",
|
||||
}),
|
||||
);
|
||||
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses cached matching credentials when access token is not configured", 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 cfg = {
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(auth).toMatchObject({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "cached-token",
|
||||
deviceId: "CACHEDDEVICE",
|
||||
});
|
||||
expect(saveMatrixCredentialsMock).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-js": {
|
||||
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",
|
||||
}),
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
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-js": {
|
||||
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-js": {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
17
extensions/matrix-js/src/matrix/client.ts
Normal file
17
extensions/matrix-js/src/matrix/client.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js";
|
||||
export { isBunRuntime } from "./client/runtime.js";
|
||||
export {
|
||||
getMatrixScopedEnvVarNames,
|
||||
hasReadyMatrixEnvAuth,
|
||||
resolveMatrixConfig,
|
||||
resolveMatrixConfigForAccount,
|
||||
resolveScopedMatrixEnvConfig,
|
||||
resolveMatrixAuth,
|
||||
} from "./client/config.js";
|
||||
export { createMatrixClient } from "./client/create-client.js";
|
||||
export {
|
||||
resolveSharedMatrixClient,
|
||||
waitForMatrixSync,
|
||||
stopSharedClient,
|
||||
stopSharedClientForAccount,
|
||||
} from "./client/shared.js";
|
||||
362
extensions/matrix-js/src/matrix/client/config.ts
Normal file
362
extensions/matrix-js/src/matrix/client/config.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "../account-config.js";
|
||||
import { MatrixClient } from "../sdk.js";
|
||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
|
||||
|
||||
function clean(value?: string): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
type MatrixEnvConfig = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
};
|
||||
|
||||
function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): MatrixEnvConfig {
|
||||
return {
|
||||
homeserver: clean(env.MATRIX_HOMESERVER),
|
||||
userId: clean(env.MATRIX_USER_ID),
|
||||
accessToken: clean(env.MATRIX_ACCESS_TOKEN) || undefined,
|
||||
password: clean(env.MATRIX_PASSWORD) || undefined,
|
||||
deviceId: clean(env.MATRIX_DEVICE_ID) || undefined,
|
||||
deviceName: clean(env.MATRIX_DEVICE_NAME) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMatrixEnvAccountToken(accountId: string): string {
|
||||
return normalizeAccountId(accountId)
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
export function getMatrixScopedEnvVarNames(accountId: string): {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
password: string;
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
} {
|
||||
const token = resolveMatrixEnvAccountToken(accountId);
|
||||
return {
|
||||
homeserver: `MATRIX_${token}_HOMESERVER`,
|
||||
userId: `MATRIX_${token}_USER_ID`,
|
||||
accessToken: `MATRIX_${token}_ACCESS_TOKEN`,
|
||||
password: `MATRIX_${token}_PASSWORD`,
|
||||
deviceId: `MATRIX_${token}_DEVICE_ID`,
|
||||
deviceName: `MATRIX_${token}_DEVICE_NAME`,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveScopedMatrixEnvConfig(
|
||||
accountId: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): MatrixEnvConfig {
|
||||
const keys = getMatrixScopedEnvVarNames(accountId);
|
||||
return {
|
||||
homeserver: clean(env[keys.homeserver]),
|
||||
userId: clean(env[keys.userId]),
|
||||
accessToken: clean(env[keys.accessToken]) || undefined,
|
||||
password: clean(env[keys.password]) || undefined,
|
||||
deviceId: clean(env[keys.deviceId]) || undefined,
|
||||
deviceName: clean(env[keys.deviceName]) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasReadyMatrixEnvAuth(config: {
|
||||
homeserver?: string;
|
||||
userId?: string;
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
}): boolean {
|
||||
const homeserver = clean(config.homeserver);
|
||||
const userId = clean(config.userId);
|
||||
const accessToken = clean(config.accessToken);
|
||||
const password = clean(config.password);
|
||||
return Boolean(homeserver && (accessToken || (userId && password)));
|
||||
}
|
||||
|
||||
export function resolveMatrixConfig(
|
||||
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): MatrixResolvedConfig {
|
||||
const matrix = resolveMatrixBaseConfig(cfg);
|
||||
const defaultScopedEnv = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, env);
|
||||
const globalEnv = resolveGlobalMatrixEnvConfig(env);
|
||||
const homeserver =
|
||||
clean(matrix.homeserver) || defaultScopedEnv.homeserver || globalEnv.homeserver;
|
||||
const userId = clean(matrix.userId) || defaultScopedEnv.userId || globalEnv.userId;
|
||||
const accessToken =
|
||||
clean(matrix.accessToken) || defaultScopedEnv.accessToken || globalEnv.accessToken || undefined;
|
||||
const password =
|
||||
clean(matrix.password) || defaultScopedEnv.password || globalEnv.password || undefined;
|
||||
const deviceId =
|
||||
clean(matrix.deviceId) || defaultScopedEnv.deviceId || globalEnv.deviceId || undefined;
|
||||
const deviceName =
|
||||
clean(matrix.deviceName) || defaultScopedEnv.deviceName || globalEnv.deviceName || undefined;
|
||||
const initialSyncLimit =
|
||||
typeof matrix.initialSyncLimit === "number"
|
||||
? Math.max(0, Math.floor(matrix.initialSyncLimit))
|
||||
: undefined;
|
||||
const encryption = matrix.encryption ?? false;
|
||||
return {
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken,
|
||||
password,
|
||||
deviceId,
|
||||
deviceName,
|
||||
initialSyncLimit,
|
||||
encryption,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMatrixConfigForAccount(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): MatrixResolvedConfig {
|
||||
const matrix = resolveMatrixBaseConfig(cfg);
|
||||
const account = findMatrixAccountConfig(cfg, accountId) ?? {};
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const scopedEnv = resolveScopedMatrixEnvConfig(normalizedAccountId, env);
|
||||
const globalEnv = resolveGlobalMatrixEnvConfig(env);
|
||||
|
||||
const accountHomeserver = clean(
|
||||
typeof account.homeserver === "string" ? account.homeserver : undefined,
|
||||
);
|
||||
const accountUserId = clean(typeof account.userId === "string" ? account.userId : undefined);
|
||||
const accountAccessToken = clean(
|
||||
typeof account.accessToken === "string" ? account.accessToken : undefined,
|
||||
);
|
||||
const accountPassword = clean(
|
||||
typeof account.password === "string" ? account.password : undefined,
|
||||
);
|
||||
const accountDeviceId = clean(
|
||||
typeof account.deviceId === "string" ? account.deviceId : undefined,
|
||||
);
|
||||
const accountDeviceName = clean(
|
||||
typeof account.deviceName === "string" ? account.deviceName : undefined,
|
||||
);
|
||||
|
||||
const homeserver =
|
||||
accountHomeserver || scopedEnv.homeserver || clean(matrix.homeserver) || globalEnv.homeserver;
|
||||
const userId = accountUserId || scopedEnv.userId || clean(matrix.userId) || globalEnv.userId;
|
||||
const accessToken =
|
||||
accountAccessToken ||
|
||||
scopedEnv.accessToken ||
|
||||
clean(matrix.accessToken) ||
|
||||
globalEnv.accessToken ||
|
||||
undefined;
|
||||
const password =
|
||||
accountPassword ||
|
||||
scopedEnv.password ||
|
||||
clean(matrix.password) ||
|
||||
globalEnv.password ||
|
||||
undefined;
|
||||
const deviceId =
|
||||
accountDeviceId ||
|
||||
scopedEnv.deviceId ||
|
||||
clean(matrix.deviceId) ||
|
||||
globalEnv.deviceId ||
|
||||
undefined;
|
||||
const deviceName =
|
||||
accountDeviceName ||
|
||||
scopedEnv.deviceName ||
|
||||
clean(matrix.deviceName) ||
|
||||
globalEnv.deviceName ||
|
||||
undefined;
|
||||
|
||||
const accountInitialSyncLimit =
|
||||
typeof account.initialSyncLimit === "number"
|
||||
? Math.max(0, Math.floor(account.initialSyncLimit))
|
||||
: undefined;
|
||||
const initialSyncLimit =
|
||||
accountInitialSyncLimit ??
|
||||
(typeof matrix.initialSyncLimit === "number"
|
||||
? Math.max(0, Math.floor(matrix.initialSyncLimit))
|
||||
: undefined);
|
||||
const encryption =
|
||||
typeof account.encryption === "boolean" ? account.encryption : (matrix.encryption ?? false);
|
||||
|
||||
return {
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken,
|
||||
password,
|
||||
deviceId,
|
||||
deviceName,
|
||||
initialSyncLimit,
|
||||
encryption,
|
||||
};
|
||||
}
|
||||
|
||||
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 accountId = params?.accountId;
|
||||
const resolved = accountId
|
||||
? resolveMatrixConfigForAccount(cfg, accountId, env)
|
||||
: resolveMatrixConfig(cfg, env);
|
||||
if (!resolved.homeserver) {
|
||||
throw new Error("Matrix homeserver is required (matrix-js.homeserver)");
|
||||
}
|
||||
|
||||
const {
|
||||
loadMatrixCredentials,
|
||||
saveMatrixCredentials,
|
||||
credentialsMatchConfig,
|
||||
touchMatrixCredentials,
|
||||
} = await import("../credentials.js");
|
||||
|
||||
const cached = loadMatrixCredentials(env, accountId);
|
||||
const cachedCredentials =
|
||||
cached &&
|
||||
credentialsMatchConfig(cached, {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: resolved.userId || "",
|
||||
})
|
||||
? cached
|
||||
: null;
|
||||
|
||||
// If we have an access token, we can fetch userId via whoami if not provided
|
||||
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.
|
||||
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) {
|
||||
await saveMatrixCredentials(
|
||||
{
|
||||
homeserver: resolved.homeserver,
|
||||
userId,
|
||||
accessToken: resolved.accessToken,
|
||||
deviceId: knownDeviceId,
|
||||
},
|
||||
env,
|
||||
accountId,
|
||||
);
|
||||
} else if (hasMatchingCachedToken) {
|
||||
await 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) {
|
||||
await 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,
|
||||
};
|
||||
}
|
||||
|
||||
if (!resolved.userId) {
|
||||
throw new Error(
|
||||
"Matrix userId is required when no access token is configured (matrix-js.userId)",
|
||||
);
|
||||
}
|
||||
|
||||
if (!resolved.password) {
|
||||
throw new Error(
|
||||
"Matrix password is required when no access token is configured (matrix-js.password)",
|
||||
);
|
||||
}
|
||||
|
||||
// Login with password using the same hardened request path as other Matrix HTTP calls.
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
const loginClient = new MatrixClient(resolved.homeserver, "");
|
||||
const 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;
|
||||
};
|
||||
|
||||
const accessToken = login.access_token?.trim();
|
||||
if (!accessToken) {
|
||||
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,
|
||||
};
|
||||
|
||||
await saveMatrixCredentials(
|
||||
{
|
||||
homeserver: auth.homeserver,
|
||||
userId: auth.userId,
|
||||
accessToken: auth.accessToken,
|
||||
deviceId: auth.deviceId,
|
||||
},
|
||||
env,
|
||||
accountId,
|
||||
);
|
||||
|
||||
return auth;
|
||||
}
|
||||
58
extensions/matrix-js/src/matrix/client/create-client.ts
Normal file
58
extensions/matrix-js/src/matrix/client/create-client.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import fs from "node:fs";
|
||||
import { MatrixClient } from "../sdk.js";
|
||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||
import {
|
||||
maybeMigrateLegacyStorage,
|
||||
resolveMatrixStoragePaths,
|
||||
writeStorageMeta,
|
||||
} from "./storage.js";
|
||||
|
||||
export async function createMatrixClient(params: {
|
||||
homeserver: string;
|
||||
userId?: string;
|
||||
accessToken: string;
|
||||
password?: string;
|
||||
deviceId?: string;
|
||||
encryption?: boolean;
|
||||
localTimeoutMs?: number;
|
||||
initialSyncLimit?: number;
|
||||
accountId?: string | null;
|
||||
autoBootstrapCrypto?: boolean;
|
||||
}): Promise<MatrixClient> {
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
const env = process.env;
|
||||
const userId = params.userId?.trim() || "unknown";
|
||||
const matrixClientUserId = params.userId?.trim() || undefined;
|
||||
|
||||
const storagePaths = resolveMatrixStoragePaths({
|
||||
homeserver: params.homeserver,
|
||||
userId,
|
||||
accessToken: params.accessToken,
|
||||
accountId: params.accountId,
|
||||
env,
|
||||
});
|
||||
maybeMigrateLegacyStorage({ storagePaths, env });
|
||||
fs.mkdirSync(storagePaths.rootDir, { recursive: true });
|
||||
|
||||
writeStorageMeta({
|
||||
storagePaths,
|
||||
homeserver: params.homeserver,
|
||||
userId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
|
||||
const cryptoDatabasePrefix = `openclaw-matrix-js-${storagePaths.accountKey}-${storagePaths.tokenHash}`;
|
||||
|
||||
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,
|
||||
autoBootstrapCrypto: params.autoBootstrapCrypto,
|
||||
});
|
||||
}
|
||||
100
extensions/matrix-js/src/matrix/client/logging.ts
Normal file
100
extensions/matrix-js/src/matrix/client/logging.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { ConsoleLogger, LogService } from "../sdk/logger.js";
|
||||
|
||||
let matrixSdkLoggingConfigured = false;
|
||||
let matrixSdkLogMode: "default" | "quiet" = "default";
|
||||
const matrixSdkBaseLogger = new ConsoleLogger();
|
||||
|
||||
type MatrixJsSdkLogger = {
|
||||
trace: (...messageOrObject: unknown[]) => void;
|
||||
debug: (...messageOrObject: unknown[]) => void;
|
||||
info: (...messageOrObject: unknown[]) => void;
|
||||
warn: (...messageOrObject: unknown[]) => void;
|
||||
error: (...messageOrObject: unknown[]) => void;
|
||||
getChild: (namespace: string) => MatrixJsSdkLogger;
|
||||
};
|
||||
|
||||
function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean {
|
||||
if (!module.includes("MatrixHttpClient")) {
|
||||
return false;
|
||||
}
|
||||
return messageOrObject.some((entry) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return false;
|
||||
}
|
||||
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
|
||||
});
|
||||
}
|
||||
|
||||
export function ensureMatrixSdkLoggingConfigured(): void {
|
||||
if (!matrixSdkLoggingConfigured) {
|
||||
matrixSdkLoggingConfigured = true;
|
||||
}
|
||||
applyMatrixSdkLogger();
|
||||
}
|
||||
|
||||
export function setMatrixSdkLogMode(mode: "default" | "quiet"): void {
|
||||
matrixSdkLogMode = mode;
|
||||
if (!matrixSdkLoggingConfigured) {
|
||||
return;
|
||||
}
|
||||
applyMatrixSdkLogger();
|
||||
}
|
||||
|
||||
export function createMatrixJsSdkClientLogger(prefix = "matrix-js"): MatrixJsSdkLogger {
|
||||
return createMatrixJsSdkLoggerInstance(prefix);
|
||||
}
|
||||
|
||||
function applyMatrixSdkLogger(): void {
|
||||
if (matrixSdkLogMode === "quiet") {
|
||||
LogService.setLogger({
|
||||
trace: () => {},
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
LogService.setLogger({
|
||||
trace: (module, ...messageOrObject) => matrixSdkBaseLogger.trace(module, ...messageOrObject),
|
||||
debug: (module, ...messageOrObject) => matrixSdkBaseLogger.debug(module, ...messageOrObject),
|
||||
info: (module, ...messageOrObject) => matrixSdkBaseLogger.info(module, ...messageOrObject),
|
||||
warn: (module, ...messageOrObject) => matrixSdkBaseLogger.warn(module, ...messageOrObject),
|
||||
error: (module, ...messageOrObject) => {
|
||||
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) {
|
||||
return;
|
||||
}
|
||||
matrixSdkBaseLogger.error(module, ...messageOrObject);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createMatrixJsSdkLoggerInstance(prefix: string): MatrixJsSdkLogger {
|
||||
const log = (method: keyof ConsoleLogger, ...messageOrObject: unknown[]): void => {
|
||||
if (matrixSdkLogMode === "quiet") {
|
||||
return;
|
||||
}
|
||||
(matrixSdkBaseLogger[method] as (module: string, ...args: unknown[]) => void)(
|
||||
prefix,
|
||||
...messageOrObject,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
trace: (...messageOrObject) => log("trace", ...messageOrObject),
|
||||
debug: (...messageOrObject) => log("debug", ...messageOrObject),
|
||||
info: (...messageOrObject) => log("info", ...messageOrObject),
|
||||
warn: (...messageOrObject) => log("warn", ...messageOrObject),
|
||||
error: (...messageOrObject) => {
|
||||
if (shouldSuppressMatrixHttpNotFound(prefix, messageOrObject)) {
|
||||
return;
|
||||
}
|
||||
log("error", ...messageOrObject);
|
||||
},
|
||||
getChild: (namespace: string) => {
|
||||
const nextNamespace = namespace.trim();
|
||||
return createMatrixJsSdkLoggerInstance(nextNamespace ? `${prefix}.${nextNamespace}` : prefix);
|
||||
},
|
||||
};
|
||||
}
|
||||
4
extensions/matrix-js/src/matrix/client/runtime.ts
Normal file
4
extensions/matrix-js/src/matrix/client/runtime.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function isBunRuntime(): boolean {
|
||||
const versions = process.versions as { bun?: string };
|
||||
return typeof versions.bun === "string";
|
||||
}
|
||||
112
extensions/matrix-js/src/matrix/client/shared.test.ts
Normal file
112
extensions/matrix-js/src/matrix/client/shared.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixAuth } from "./types.js";
|
||||
|
||||
const resolveMatrixAuthMock = vi.hoisted(() => vi.fn());
|
||||
const createMatrixClientMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./config.js", () => ({
|
||||
resolveMatrixAuth: resolveMatrixAuthMock,
|
||||
}));
|
||||
|
||||
vi.mock("./create-client.js", () => ({
|
||||
createMatrixClient: createMatrixClientMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
resolveSharedMatrixClient,
|
||||
stopSharedClient,
|
||||
stopSharedClientForAccount,
|
||||
} from "./shared.js";
|
||||
|
||||
function authFor(accountId: string): MatrixAuth {
|
||||
return {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: `@${accountId}:example.org`,
|
||||
accessToken: `token-${accountId}`,
|
||||
password: "secret",
|
||||
deviceId: `${accountId.toUpperCase()}-DEVICE`,
|
||||
deviceName: `${accountId} device`,
|
||||
initialSyncLimit: undefined,
|
||||
encryption: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockClient(name: string) {
|
||||
const client = {
|
||||
name,
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(() => undefined),
|
||||
getJoinedRooms: vi.fn(async () => [] as string[]),
|
||||
crypto: undefined,
|
||||
};
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("resolveSharedMatrixClient", () => {
|
||||
beforeEach(() => {
|
||||
resolveMatrixAuthMock.mockReset();
|
||||
createMatrixClientMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopSharedClient();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps account clients isolated when resolves are interleaved", async () => {
|
||||
const mainAuth = authFor("main");
|
||||
const poeAuth = authFor("ops");
|
||||
const mainClient = createMockClient("main");
|
||||
const poeClient = createMockClient("ops");
|
||||
|
||||
resolveMatrixAuthMock.mockImplementation(async ({ accountId }: { accountId?: string }) =>
|
||||
accountId === "ops" ? poeAuth : mainAuth,
|
||||
);
|
||||
createMatrixClientMock.mockImplementation(async ({ accountId }: { accountId?: string }) => {
|
||||
if (accountId === "ops") {
|
||||
return poeClient;
|
||||
}
|
||||
return mainClient;
|
||||
});
|
||||
|
||||
const firstMain = await resolveSharedMatrixClient({ accountId: "main", startClient: false });
|
||||
const firstPoe = await resolveSharedMatrixClient({ accountId: "ops", startClient: false });
|
||||
const secondMain = await resolveSharedMatrixClient({ accountId: "main" });
|
||||
|
||||
expect(firstMain).toBe(mainClient);
|
||||
expect(firstPoe).toBe(poeClient);
|
||||
expect(secondMain).toBe(mainClient);
|
||||
expect(createMatrixClientMock).toHaveBeenCalledTimes(2);
|
||||
expect(mainClient.start).toHaveBeenCalledTimes(1);
|
||||
expect(poeClient.start).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("stops only the targeted account client", async () => {
|
||||
const mainAuth = authFor("main");
|
||||
const poeAuth = authFor("ops");
|
||||
const mainClient = createMockClient("main");
|
||||
const poeClient = createMockClient("ops");
|
||||
|
||||
resolveMatrixAuthMock.mockImplementation(async ({ accountId }: { accountId?: string }) =>
|
||||
accountId === "ops" ? poeAuth : mainAuth,
|
||||
);
|
||||
createMatrixClientMock.mockImplementation(async ({ accountId }: { accountId?: string }) => {
|
||||
if (accountId === "ops") {
|
||||
return poeClient;
|
||||
}
|
||||
return mainClient;
|
||||
});
|
||||
|
||||
await resolveSharedMatrixClient({ accountId: "main", startClient: false });
|
||||
await resolveSharedMatrixClient({ accountId: "ops", startClient: false });
|
||||
|
||||
stopSharedClientForAccount(mainAuth, "main");
|
||||
|
||||
expect(mainClient.stop).toHaveBeenCalledTimes(1);
|
||||
expect(poeClient.stop).toHaveBeenCalledTimes(0);
|
||||
|
||||
stopSharedClient();
|
||||
|
||||
expect(poeClient.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
193
extensions/matrix-js/src/matrix/client/shared.ts
Normal file
193
extensions/matrix-js/src/matrix/client/shared.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { LogService } from "../sdk/logger.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;
|
||||
key: string;
|
||||
started: boolean;
|
||||
cryptoReady: boolean;
|
||||
startPromise: Promise<void> | null;
|
||||
};
|
||||
|
||||
const sharedClientStates = new Map<string, SharedMatrixClientState>();
|
||||
const sharedClientPromises = new Map<string, Promise<SharedMatrixClientState>>();
|
||||
|
||||
function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string {
|
||||
return [
|
||||
auth.homeserver,
|
||||
auth.userId,
|
||||
auth.accessToken,
|
||||
auth.encryption ? "e2ee" : "plain",
|
||||
accountId ?? DEFAULT_ACCOUNT_KEY,
|
||||
].join("|");
|
||||
}
|
||||
|
||||
async function createSharedMatrixClient(params: {
|
||||
auth: MatrixAuth;
|
||||
timeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
}): Promise<SharedMatrixClientState> {
|
||||
const client = await createMatrixClient({
|
||||
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 {
|
||||
client,
|
||||
key: buildSharedClientKey(params.auth, params.accountId),
|
||||
started: false,
|
||||
cryptoReady: false,
|
||||
startPromise: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureSharedClientStarted(params: {
|
||||
state: SharedMatrixClientState;
|
||||
timeoutMs?: number;
|
||||
initialSyncLimit?: number;
|
||||
encryption?: boolean;
|
||||
}): Promise<void> {
|
||||
if (params.state.started) {
|
||||
return;
|
||||
}
|
||||
if (params.state.startPromise) {
|
||||
await params.state.startPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
params.state.startPromise = (async () => {
|
||||
const client = params.state.client;
|
||||
|
||||
// Initialize crypto if enabled
|
||||
if (params.encryption && !params.state.cryptoReady) {
|
||||
try {
|
||||
const joinedRooms = await client.getJoinedRooms();
|
||||
if (client.crypto) {
|
||||
await client.crypto.prepare(joinedRooms);
|
||||
params.state.cryptoReady = true;
|
||||
}
|
||||
} catch (err) {
|
||||
LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err);
|
||||
}
|
||||
}
|
||||
|
||||
await client.start();
|
||||
params.state.started = true;
|
||||
})();
|
||||
|
||||
try {
|
||||
await params.state.startPromise;
|
||||
} finally {
|
||||
params.state.startPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveSharedMatrixClient(
|
||||
params: {
|
||||
cfg?: CoreConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
auth?: MatrixAuth;
|
||||
startClient?: boolean;
|
||||
accountId?: string | null;
|
||||
} = {},
|
||||
): Promise<MatrixClient> {
|
||||
const auth =
|
||||
params.auth ??
|
||||
(await resolveMatrixAuth({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
accountId: params.accountId,
|
||||
}));
|
||||
const key = buildSharedClientKey(auth, params.accountId);
|
||||
const shouldStart = params.startClient !== false;
|
||||
|
||||
const existingState = sharedClientStates.get(key);
|
||||
if (existingState) {
|
||||
if (shouldStart) {
|
||||
await ensureSharedClientStarted({
|
||||
state: existingState,
|
||||
timeoutMs: params.timeoutMs,
|
||||
initialSyncLimit: auth.initialSyncLimit,
|
||||
encryption: auth.encryption,
|
||||
});
|
||||
}
|
||||
return existingState.client;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
return pending.client;
|
||||
}
|
||||
|
||||
const creationPromise = createSharedMatrixClient({
|
||||
auth,
|
||||
timeoutMs: params.timeoutMs,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
sharedClientPromises.set(key, creationPromise);
|
||||
|
||||
try {
|
||||
const created = await creationPromise;
|
||||
sharedClientStates.set(key, created);
|
||||
if (shouldStart) {
|
||||
await ensureSharedClientStarted({
|
||||
state: created,
|
||||
timeoutMs: params.timeoutMs,
|
||||
initialSyncLimit: auth.initialSyncLimit,
|
||||
encryption: auth.encryption,
|
||||
});
|
||||
}
|
||||
return created.client;
|
||||
} finally {
|
||||
sharedClientPromises.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForMatrixSync(_params: {
|
||||
client: MatrixClient;
|
||||
timeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
// matrix-js-sdk handles sync lifecycle in start() for this integration.
|
||||
// This is kept for API compatibility but is essentially a no-op now
|
||||
}
|
||||
|
||||
export function stopSharedClient(): void {
|
||||
for (const state of sharedClientStates.values()) {
|
||||
state.client.stop();
|
||||
}
|
||||
sharedClientStates.clear();
|
||||
sharedClientPromises.clear();
|
||||
}
|
||||
|
||||
export function stopSharedClientForAccount(auth: MatrixAuth, accountId?: string | null): void {
|
||||
const key = buildSharedClientKey(auth, accountId);
|
||||
const state = sharedClientStates.get(key);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
state.client.stop();
|
||||
sharedClientStates.delete(key);
|
||||
sharedClientPromises.delete(key);
|
||||
}
|
||||
134
extensions/matrix-js/src/matrix/client/storage.ts
Normal file
134
extensions/matrix-js/src/matrix/client/storage.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { MatrixStoragePaths } from "./types.js";
|
||||
|
||||
export const DEFAULT_ACCOUNT_KEY = "default";
|
||||
const STORAGE_META_FILENAME = "storage-meta.json";
|
||||
|
||||
function sanitizePathSegment(value: string): string {
|
||||
const cleaned = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
return cleaned || "unknown";
|
||||
}
|
||||
|
||||
function resolveHomeserverKey(homeserver: string): string {
|
||||
try {
|
||||
const url = new URL(homeserver);
|
||||
if (url.host) {
|
||||
return sanitizePathSegment(url.host);
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return sanitizePathSegment(homeserver);
|
||||
}
|
||||
|
||||
function hashAccessToken(accessToken: string): string {
|
||||
return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): {
|
||||
storagePath: string;
|
||||
cryptoPath: string;
|
||||
} {
|
||||
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||
return {
|
||||
storagePath: path.join(stateDir, "credentials", "matrix-js", "bot-storage.json"),
|
||||
cryptoPath: path.join(stateDir, "credentials", "matrix-js", "crypto"),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMatrixStoragePaths(params: {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
accountId?: string | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): MatrixStoragePaths {
|
||||
const env = params.env ?? process.env;
|
||||
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||
const accountKey = sanitizePathSegment(params.accountId ?? DEFAULT_ACCOUNT_KEY);
|
||||
const userKey = sanitizePathSegment(params.userId);
|
||||
const serverKey = resolveHomeserverKey(params.homeserver);
|
||||
const tokenHash = hashAccessToken(params.accessToken);
|
||||
const rootDir = path.join(
|
||||
stateDir,
|
||||
"credentials",
|
||||
"matrix-js",
|
||||
"accounts",
|
||||
accountKey,
|
||||
`${serverKey}__${userKey}`,
|
||||
tokenHash,
|
||||
);
|
||||
return {
|
||||
rootDir,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export function maybeMigrateLegacyStorage(params: {
|
||||
storagePaths: MatrixStoragePaths;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): void {
|
||||
const legacy = resolveLegacyStoragePaths(params.env);
|
||||
const hasLegacyStorage = fs.existsSync(legacy.storagePath);
|
||||
const hasLegacyCrypto = fs.existsSync(legacy.cryptoPath);
|
||||
const hasNewStorage =
|
||||
fs.existsSync(params.storagePaths.storagePath) || fs.existsSync(params.storagePaths.cryptoPath);
|
||||
|
||||
if (!hasLegacyStorage && !hasLegacyCrypto) {
|
||||
return;
|
||||
}
|
||||
if (hasNewStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
|
||||
if (hasLegacyStorage) {
|
||||
try {
|
||||
fs.renameSync(legacy.storagePath, params.storagePaths.storagePath);
|
||||
} catch {
|
||||
// Ignore migration failures; new store will be created.
|
||||
}
|
||||
}
|
||||
if (hasLegacyCrypto) {
|
||||
try {
|
||||
fs.renameSync(legacy.cryptoPath, params.storagePaths.cryptoPath);
|
||||
} catch {
|
||||
// Ignore migration failures; new store will be created.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStorageMeta(params: {
|
||||
storagePaths: MatrixStoragePaths;
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accountId?: string | null;
|
||||
}): void {
|
||||
try {
|
||||
const payload = {
|
||||
homeserver: params.homeserver,
|
||||
userId: params.userId,
|
||||
accountId: params.accountId ?? DEFAULT_ACCOUNT_KEY,
|
||||
accessTokenHash: params.storagePaths.tokenHash,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
|
||||
fs.writeFileSync(params.storagePaths.metaPath, JSON.stringify(payload, null, 2), "utf-8");
|
||||
} catch {
|
||||
// ignore meta write failures
|
||||
}
|
||||
}
|
||||
39
extensions/matrix-js/src/matrix/client/types.ts
Normal file
39
extensions/matrix-js/src/matrix/client/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export type MatrixResolvedConfig = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken?: string;
|
||||
deviceId?: string;
|
||||
password?: string;
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: number;
|
||||
encryption?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticated Matrix configuration.
|
||||
* Note: deviceId is NOT included here because it's implicit in the accessToken.
|
||||
* The crypto storage assumes the device ID (and thus access token) does not change
|
||||
* between restarts. If the access token becomes invalid or crypto storage is lost,
|
||||
* both will need to be recreated together.
|
||||
*/
|
||||
export type MatrixAuth = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
password?: string;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: number;
|
||||
encryption?: boolean;
|
||||
};
|
||||
|
||||
export type MatrixStoragePaths = {
|
||||
rootDir: string;
|
||||
storagePath: string;
|
||||
cryptoPath: string;
|
||||
metaPath: string;
|
||||
recoveryKeyPath: string;
|
||||
idbSnapshotPath: string;
|
||||
accountKey: string;
|
||||
tokenHash: string;
|
||||
};
|
||||
50
extensions/matrix-js/src/matrix/config-update.test.ts
Normal file
50
extensions/matrix-js/src/matrix/config-update.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { updateMatrixAccountConfig } from "./config-update.js";
|
||||
|
||||
describe("updateMatrixAccountConfig", () => {
|
||||
it("supports explicit null clears and boolean false values", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
accounts: {
|
||||
default: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "old-token",
|
||||
password: "old-password",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const updated = updateMatrixAccountConfig(cfg, "default", {
|
||||
accessToken: "new-token",
|
||||
password: null,
|
||||
userId: null,
|
||||
encryption: false,
|
||||
});
|
||||
|
||||
expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({
|
||||
accessToken: "new-token",
|
||||
encryption: false,
|
||||
});
|
||||
expect(updated.channels?.["matrix-js"]?.accounts?.default?.password).toBeUndefined();
|
||||
expect(updated.channels?.["matrix-js"]?.accounts?.default?.userId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes account id and defaults account enabled=true", () => {
|
||||
const updated = updateMatrixAccountConfig({} as CoreConfig, "Main Bot", {
|
||||
name: "Main Bot",
|
||||
homeserver: "https://matrix.example.org",
|
||||
});
|
||||
|
||||
expect(updated.channels?.["matrix-js"]?.accounts?.["main-bot"]).toMatchObject({
|
||||
name: "Main Bot",
|
||||
homeserver: "https://matrix.example.org",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
102
extensions/matrix-js/src/matrix/config-update.ts
Normal file
102
extensions/matrix-js/src/matrix/config-update.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/matrix-js";
|
||||
import type { CoreConfig, MatrixConfig } from "../types.js";
|
||||
|
||||
export type MatrixAccountPatch = {
|
||||
name?: string | null;
|
||||
enabled?: boolean;
|
||||
homeserver?: string | null;
|
||||
userId?: string | null;
|
||||
accessToken?: string | null;
|
||||
password?: string | null;
|
||||
deviceName?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
encryption?: boolean | null;
|
||||
initialSyncLimit?: number | null;
|
||||
};
|
||||
|
||||
function applyNullableStringField(
|
||||
target: Record<string, unknown>,
|
||||
key: keyof MatrixAccountPatch,
|
||||
value: string | null | undefined,
|
||||
): void {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
if (value === null) {
|
||||
delete target[key];
|
||||
return;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
delete target[key];
|
||||
return;
|
||||
}
|
||||
target[key] = trimmed;
|
||||
}
|
||||
|
||||
export function updateMatrixAccountConfig(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
patch: MatrixAccountPatch,
|
||||
): CoreConfig {
|
||||
const matrix = cfg.channels?.["matrix-js"] ?? {};
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const existingAccount = (matrix.accounts?.[normalizedAccountId] ?? {}) as MatrixConfig;
|
||||
const nextAccount: Record<string, unknown> = { ...existingAccount };
|
||||
|
||||
if (patch.name !== undefined) {
|
||||
if (patch.name === null) {
|
||||
delete nextAccount.name;
|
||||
} else {
|
||||
const trimmed = patch.name.trim();
|
||||
if (trimmed) {
|
||||
nextAccount.name = trimmed;
|
||||
} else {
|
||||
delete nextAccount.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof patch.enabled === "boolean") {
|
||||
nextAccount.enabled = patch.enabled;
|
||||
} else if (typeof nextAccount.enabled !== "boolean") {
|
||||
nextAccount.enabled = true;
|
||||
}
|
||||
|
||||
applyNullableStringField(nextAccount, "homeserver", patch.homeserver);
|
||||
applyNullableStringField(nextAccount, "userId", patch.userId);
|
||||
applyNullableStringField(nextAccount, "accessToken", patch.accessToken);
|
||||
applyNullableStringField(nextAccount, "password", patch.password);
|
||||
applyNullableStringField(nextAccount, "deviceName", patch.deviceName);
|
||||
applyNullableStringField(nextAccount, "avatarUrl", patch.avatarUrl);
|
||||
|
||||
if (patch.initialSyncLimit !== undefined) {
|
||||
if (patch.initialSyncLimit === null) {
|
||||
delete nextAccount.initialSyncLimit;
|
||||
} else {
|
||||
nextAccount.initialSyncLimit = Math.max(0, Math.floor(patch.initialSyncLimit));
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.encryption !== undefined) {
|
||||
if (patch.encryption === null) {
|
||||
delete nextAccount.encryption;
|
||||
} else {
|
||||
nextAccount.encryption = patch.encryption;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
"matrix-js": {
|
||||
...matrix,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...matrix.accounts,
|
||||
[normalizedAccountId]: nextAccount as MatrixConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
80
extensions/matrix-js/src/matrix/credentials.test.ts
Normal file
80
extensions/matrix-js/src/matrix/credentials.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { setMatrixRuntime } from "../runtime.js";
|
||||
import {
|
||||
loadMatrixCredentials,
|
||||
resolveMatrixCredentialsPath,
|
||||
saveMatrixCredentials,
|
||||
touchMatrixCredentials,
|
||||
} from "./credentials.js";
|
||||
|
||||
describe("matrix credentials storage", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function setupStateDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-creds-"));
|
||||
tempDirs.push(dir);
|
||||
setMatrixRuntime({
|
||||
state: {
|
||||
resolveStateDir: () => dir,
|
||||
},
|
||||
} as never);
|
||||
return dir;
|
||||
}
|
||||
|
||||
it("writes credentials atomically with secure file permissions", async () => {
|
||||
setupStateDir();
|
||||
await saveMatrixCredentials(
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token",
|
||||
deviceId: "DEVICE123",
|
||||
},
|
||||
{},
|
||||
"ops",
|
||||
);
|
||||
|
||||
const credPath = resolveMatrixCredentialsPath({}, "ops");
|
||||
expect(fs.existsSync(credPath)).toBe(true);
|
||||
const mode = fs.statSync(credPath).mode & 0o777;
|
||||
expect(mode).toBe(0o600);
|
||||
});
|
||||
|
||||
it("touch updates lastUsedAt while preserving createdAt", async () => {
|
||||
setupStateDir();
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-03-01T10:00:00.000Z"));
|
||||
await saveMatrixCredentials(
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token",
|
||||
},
|
||||
{},
|
||||
"default",
|
||||
);
|
||||
const initial = loadMatrixCredentials({}, "default");
|
||||
expect(initial).not.toBeNull();
|
||||
|
||||
vi.setSystemTime(new Date("2026-03-01T10:05:00.000Z"));
|
||||
await touchMatrixCredentials({}, "default");
|
||||
const touched = loadMatrixCredentials({}, "default");
|
||||
expect(touched).not.toBeNull();
|
||||
|
||||
expect(touched?.createdAt).toBe(initial?.createdAt);
|
||||
expect(touched?.lastUsedAt).toBe("2026-03-01T10:05:00.000Z");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
123
extensions/matrix-js/src/matrix/credentials.ts
Normal file
123
extensions/matrix-js/src/matrix/credentials.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { writeJsonFileAtomically } from "openclaw/plugin-sdk/matrix-js";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
|
||||
export type MatrixStoredCredentials = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
deviceId?: string;
|
||||
createdAt: string;
|
||||
lastUsedAt?: string;
|
||||
};
|
||||
|
||||
function credentialsFilename(accountId?: string | null): string {
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
if (normalized === DEFAULT_ACCOUNT_ID) {
|
||||
return "credentials.json";
|
||||
}
|
||||
// normalizeAccountId produces lowercase [a-z0-9-] strings, already filesystem-safe.
|
||||
// Different raw IDs that normalize to the same value are the same logical account.
|
||||
return `credentials-${normalized}.json`;
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
stateDir?: string,
|
||||
): string {
|
||||
const resolvedStateDir = stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||
return path.join(resolvedStateDir, "credentials", "matrix-js");
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsPath(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): string {
|
||||
const dir = resolveMatrixCredentialsDir(env);
|
||||
return path.join(dir, credentialsFilename(accountId));
|
||||
}
|
||||
|
||||
export function loadMatrixCredentials(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): MatrixStoredCredentials | null {
|
||||
const credPath = resolveMatrixCredentialsPath(env, accountId);
|
||||
try {
|
||||
if (!fs.existsSync(credPath)) {
|
||||
return null;
|
||||
}
|
||||
const raw = fs.readFileSync(credPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Partial<MatrixStoredCredentials>;
|
||||
if (
|
||||
typeof parsed.homeserver !== "string" ||
|
||||
typeof parsed.userId !== "string" ||
|
||||
typeof parsed.accessToken !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed as MatrixStoredCredentials;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMatrixCredentials(
|
||||
credentials: Omit<MatrixStoredCredentials, "createdAt" | "lastUsedAt">,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): Promise<void> {
|
||||
const credPath = resolveMatrixCredentialsPath(env, accountId);
|
||||
|
||||
const existing = loadMatrixCredentials(env, accountId);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const toSave: MatrixStoredCredentials = {
|
||||
...credentials,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
lastUsedAt: now,
|
||||
};
|
||||
|
||||
await writeJsonFileAtomically(credPath, toSave);
|
||||
}
|
||||
|
||||
export async function touchMatrixCredentials(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): Promise<void> {
|
||||
const existing = loadMatrixCredentials(env, accountId);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
existing.lastUsedAt = new Date().toISOString();
|
||||
const credPath = resolveMatrixCredentialsPath(env, accountId);
|
||||
await writeJsonFileAtomically(credPath, existing);
|
||||
}
|
||||
|
||||
export function clearMatrixCredentials(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): void {
|
||||
const credPath = resolveMatrixCredentialsPath(env, accountId);
|
||||
try {
|
||||
if (fs.existsSync(credPath)) {
|
||||
fs.unlinkSync(credPath);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function credentialsMatchConfig(
|
||||
stored: MatrixStoredCredentials,
|
||||
config: { homeserver: string; userId: string },
|
||||
): boolean {
|
||||
// If userId is empty (token-based auth), only match homeserver
|
||||
if (!config.userId) {
|
||||
return stored.homeserver === config.homeserver;
|
||||
}
|
||||
return stored.homeserver === config.homeserver && stored.userId === config.userId;
|
||||
}
|
||||
157
extensions/matrix-js/src/matrix/deps.ts
Normal file
157
extensions/matrix-js/src/matrix/deps.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
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/matrix-js";
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
export function isMatrixSdkAvailable(): boolean {
|
||||
return resolveMissingMatrixPackages().length === 0;
|
||||
}
|
||||
|
||||
function resolvePluginRoot(): string {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
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>;
|
||||
}): Promise<void> {
|
||||
if (isMatrixSdkAvailable()) {
|
||||
return;
|
||||
}
|
||||
const confirm = params.confirm;
|
||||
if (confirm) {
|
||||
const ok = await confirm(
|
||||
"Matrix requires matrix-js-sdk and @matrix-org/matrix-sdk-crypto-nodejs. Install now?",
|
||||
);
|
||||
if (!ok) {
|
||||
throw new Error(
|
||||
"Matrix requires matrix-js-sdk and @matrix-org/matrix-sdk-crypto-nodejs (install dependencies first).",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const root = resolvePluginRoot();
|
||||
const command = fs.existsSync(path.join(root, "pnpm-lock.yaml"))
|
||||
? ["pnpm", "install"]
|
||||
: ["npm", "install", "--omit=dev", "--silent"];
|
||||
params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`);
|
||||
const result = await runFixedCommandWithTimeout({
|
||||
argv: command,
|
||||
cwd: root,
|
||||
timeoutMs: 300_000,
|
||||
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
result.stderr.trim() || result.stdout.trim() || "Matrix dependency install failed.",
|
||||
);
|
||||
}
|
||||
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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
33
extensions/matrix-js/src/matrix/format.test.ts
Normal file
33
extensions/matrix-js/src/matrix/format.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markdownToMatrixHtml } from "./format.js";
|
||||
|
||||
describe("markdownToMatrixHtml", () => {
|
||||
it("renders basic inline formatting", () => {
|
||||
const html = markdownToMatrixHtml("hi _there_ **boss** `code`");
|
||||
expect(html).toContain("<em>there</em>");
|
||||
expect(html).toContain("<strong>boss</strong>");
|
||||
expect(html).toContain("<code>code</code>");
|
||||
});
|
||||
|
||||
it("renders links as HTML", () => {
|
||||
const html = markdownToMatrixHtml("see [docs](https://example.com)");
|
||||
expect(html).toContain('<a href="https://example.com">docs</a>');
|
||||
});
|
||||
|
||||
it("escapes raw HTML", () => {
|
||||
const html = markdownToMatrixHtml("<b>nope</b>");
|
||||
expect(html).toContain("<b>nope</b>");
|
||||
expect(html).not.toContain("<b>nope</b>");
|
||||
});
|
||||
|
||||
it("flattens images into alt text", () => {
|
||||
const html = markdownToMatrixHtml("");
|
||||
expect(html).toContain("alt");
|
||||
expect(html).not.toContain("<img");
|
||||
});
|
||||
|
||||
it("preserves line breaks", () => {
|
||||
const html = markdownToMatrixHtml("line1\nline2");
|
||||
expect(html).toContain("<br");
|
||||
});
|
||||
});
|
||||
22
extensions/matrix-js/src/matrix/format.ts
Normal file
22
extensions/matrix-js/src/matrix/format.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import MarkdownIt from "markdown-it";
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
breaks: true,
|
||||
typographer: false,
|
||||
});
|
||||
|
||||
md.enable("strikethrough");
|
||||
|
||||
const { escapeHtml } = md.utils;
|
||||
|
||||
md.renderer.rules.image = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
|
||||
|
||||
md.renderer.rules.html_block = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
|
||||
md.renderer.rules.html_inline = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
|
||||
|
||||
export function markdownToMatrixHtml(markdown: string): string {
|
||||
const rendered = md.render(markdown ?? "");
|
||||
return rendered.trimEnd();
|
||||
}
|
||||
11
extensions/matrix-js/src/matrix/index.ts
Normal file
11
extensions/matrix-js/src/matrix/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { monitorMatrixProvider } from "./monitor/index.js";
|
||||
export { probeMatrix } from "./probe.js";
|
||||
export {
|
||||
reactMatrixMessage,
|
||||
resolveMatrixRoomId,
|
||||
sendReadReceiptMatrix,
|
||||
sendMessageMatrix,
|
||||
sendPollMatrix,
|
||||
sendTypingMatrix,
|
||||
} from "./send.js";
|
||||
export { resolveMatrixAuth, resolveSharedMatrixClient } from "./client.js";
|
||||
45
extensions/matrix-js/src/matrix/monitor/allowlist.test.ts
Normal file
45
extensions/matrix-js/src/matrix/monitor/allowlist.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeMatrixAllowList, resolveMatrixAllowListMatch } from "./allowlist.js";
|
||||
|
||||
describe("resolveMatrixAllowListMatch", () => {
|
||||
it("matches full user IDs and prefixes", () => {
|
||||
const userId = "@Alice:Example.org";
|
||||
const direct = resolveMatrixAllowListMatch({
|
||||
allowList: normalizeMatrixAllowList(["@alice:example.org"]),
|
||||
userId,
|
||||
});
|
||||
expect(direct.allowed).toBe(true);
|
||||
expect(direct.matchSource).toBe("id");
|
||||
|
||||
const prefixedMatrix = resolveMatrixAllowListMatch({
|
||||
allowList: normalizeMatrixAllowList(["matrix:@alice:example.org"]),
|
||||
userId,
|
||||
});
|
||||
expect(prefixedMatrix.allowed).toBe(true);
|
||||
expect(prefixedMatrix.matchSource).toBe("prefixed-id");
|
||||
|
||||
const prefixedUser = resolveMatrixAllowListMatch({
|
||||
allowList: normalizeMatrixAllowList(["user:@alice:example.org"]),
|
||||
userId,
|
||||
});
|
||||
expect(prefixedUser.allowed).toBe(true);
|
||||
expect(prefixedUser.matchSource).toBe("prefixed-user");
|
||||
});
|
||||
|
||||
it("ignores display names and localparts", () => {
|
||||
const match = resolveMatrixAllowListMatch({
|
||||
allowList: normalizeMatrixAllowList(["alice", "Alice"]),
|
||||
userId: "@alice:example.org",
|
||||
});
|
||||
expect(match.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("matches wildcard", () => {
|
||||
const match = resolveMatrixAllowListMatch({
|
||||
allowList: normalizeMatrixAllowList(["*"]),
|
||||
userId: "@alice:example.org",
|
||||
});
|
||||
expect(match.allowed).toBe(true);
|
||||
expect(match.matchSource).toBe("wildcard");
|
||||
});
|
||||
});
|
||||
103
extensions/matrix-js/src/matrix/monitor/allowlist.ts
Normal file
103
extensions/matrix-js/src/matrix/monitor/allowlist.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { AllowlistMatch } from "openclaw/plugin-sdk/matrix-js";
|
||||
|
||||
function normalizeAllowList(list?: Array<string | number>) {
|
||||
return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeMatrixUser(raw?: string | null): string {
|
||||
const value = (raw ?? "").trim();
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
if (!value.startsWith("@") || !value.includes(":")) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
const withoutAt = value.slice(1);
|
||||
const splitIndex = withoutAt.indexOf(":");
|
||||
if (splitIndex === -1) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
const localpart = withoutAt.slice(0, splitIndex).toLowerCase();
|
||||
const server = withoutAt.slice(splitIndex + 1).toLowerCase();
|
||||
if (!server) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
return `@${localpart}:${server.toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function normalizeMatrixUserId(raw?: string | null): string {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (lowered.startsWith("matrix:")) {
|
||||
return normalizeMatrixUser(trimmed.slice("matrix:".length));
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
return normalizeMatrixUser(trimmed.slice("user:".length));
|
||||
}
|
||||
return normalizeMatrixUser(trimmed);
|
||||
}
|
||||
|
||||
function normalizeMatrixAllowListEntry(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed === "*") {
|
||||
return trimmed;
|
||||
}
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (lowered.startsWith("matrix:")) {
|
||||
return `matrix:${normalizeMatrixUser(trimmed.slice("matrix:".length))}`;
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
return `user:${normalizeMatrixUser(trimmed.slice("user:".length))}`;
|
||||
}
|
||||
return normalizeMatrixUser(trimmed);
|
||||
}
|
||||
|
||||
export function normalizeMatrixAllowList(list?: Array<string | number>) {
|
||||
return normalizeAllowList(list).map((entry) => normalizeMatrixAllowListEntry(entry));
|
||||
}
|
||||
|
||||
export type MatrixAllowListMatch = AllowlistMatch<
|
||||
"wildcard" | "id" | "prefixed-id" | "prefixed-user"
|
||||
>;
|
||||
|
||||
export function resolveMatrixAllowListMatch(params: {
|
||||
allowList: string[];
|
||||
userId?: string;
|
||||
}): MatrixAllowListMatch {
|
||||
const allowList = params.allowList;
|
||||
if (allowList.length === 0) {
|
||||
return { allowed: false };
|
||||
}
|
||||
if (allowList.includes("*")) {
|
||||
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
||||
}
|
||||
const userId = normalizeMatrixUser(params.userId);
|
||||
const candidates: Array<{ value?: string; source: MatrixAllowListMatch["matchSource"] }> = [
|
||||
{ value: userId, source: "id" },
|
||||
{ value: userId ? `matrix:${userId}` : "", source: "prefixed-id" },
|
||||
{ value: userId ? `user:${userId}` : "", source: "prefixed-user" },
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.value) {
|
||||
continue;
|
||||
}
|
||||
if (allowList.includes(candidate.value)) {
|
||||
return {
|
||||
allowed: true,
|
||||
matchKey: candidate.value,
|
||||
matchSource: candidate.source,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
export function resolveMatrixAllowListMatches(params: { allowList: string[]; userId?: string }) {
|
||||
return resolveMatrixAllowListMatch(params).allowed;
|
||||
}
|
||||
127
extensions/matrix-js/src/matrix/monitor/auto-join.test.ts
Normal file
127
extensions/matrix-js/src/matrix/monitor/auto-join.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix-js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.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-js": {
|
||||
autoJoin: "always",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
registerMatrixAutoJoin({
|
||||
client,
|
||||
cfg,
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as import("openclaw/plugin-sdk/matrix-js").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-js": {
|
||||
autoJoin: "allowlist",
|
||||
autoJoinAllowlist: ["#allowed:example.org"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
registerMatrixAutoJoin({
|
||||
client,
|
||||
cfg,
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as import("openclaw/plugin-sdk/matrix-js").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-js": {
|
||||
autoJoin: "allowlist",
|
||||
autoJoinAllowlist: [" #allowed:example.org "],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
registerMatrixAutoJoin({
|
||||
client,
|
||||
cfg,
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as import("openclaw/plugin-sdk/matrix-js").RuntimeEnv,
|
||||
});
|
||||
|
||||
const inviteHandler = getInviteHandler();
|
||||
expect(inviteHandler).toBeTruthy();
|
||||
await inviteHandler!("!room:example.org", {});
|
||||
|
||||
expect(joinRoom).toHaveBeenCalledWith("!room:example.org");
|
||||
});
|
||||
});
|
||||
75
extensions/matrix-js/src/matrix/monitor/auto-join.ts
Normal file
75
extensions/matrix-js/src/matrix/monitor/auto-join.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/matrix-js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
|
||||
export function registerMatrixAutoJoin(params: {
|
||||
client: MatrixClient;
|
||||
cfg: CoreConfig;
|
||||
runtime: RuntimeEnv;
|
||||
}) {
|
||||
const { client, cfg, runtime } = params;
|
||||
const core = getMatrixRuntime();
|
||||
const logVerbose = (message: string) => {
|
||||
if (!core.logging.shouldLogVerbose()) {
|
||||
return;
|
||||
}
|
||||
runtime.log?.(message);
|
||||
};
|
||||
const autoJoin = cfg.channels?.["matrix-js"]?.autoJoin ?? "always";
|
||||
const autoJoinAllowlist = new Set(
|
||||
(cfg.channels?.["matrix-js"]?.autoJoinAllowlist ?? [])
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
if (autoJoin === "off") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoJoin === "always") {
|
||||
logVerbose("matrix: auto-join enabled for all invites");
|
||||
} else {
|
||||
logVerbose("matrix: auto-join enabled for allowlist invites");
|
||||
}
|
||||
|
||||
// Handle invites directly so both "always" and "allowlist" modes share the same path.
|
||||
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
|
||||
}
|
||||
|
||||
const allowed =
|
||||
autoJoinAllowlist.has("*") ||
|
||||
autoJoinAllowlist.has(roomId) ||
|
||||
(alias ? autoJoinAllowlist.has(alias) : false) ||
|
||||
altAliases.some((value) => autoJoinAllowlist.has(value));
|
||||
|
||||
if (!allowed) {
|
||||
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await client.joinRoom(roomId);
|
||||
logVerbose(`matrix: joined room ${roomId}`);
|
||||
} catch (err) {
|
||||
runtime.error?.(`matrix: failed to join room ${roomId}: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
104
extensions/matrix-js/src/matrix/monitor/direct.ts
Normal file
104
extensions/matrix-js/src/matrix/monitor/direct.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
|
||||
type DirectMessageCheck = {
|
||||
roomId: string;
|
||||
senderId?: string;
|
||||
selfUserId?: string;
|
||||
};
|
||||
|
||||
type DirectRoomTrackerOptions = {
|
||||
log?: (message: string) => void;
|
||||
};
|
||||
|
||||
const DM_CACHE_TTL_MS = 30_000;
|
||||
|
||||
export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTrackerOptions = {}) {
|
||||
const log = opts.log ?? (() => {});
|
||||
let lastDmUpdateMs = 0;
|
||||
let cachedSelfUserId: string | null = null;
|
||||
const memberCountCache = new Map<string, { count: number; ts: number }>();
|
||||
|
||||
const ensureSelfUserId = async (): Promise<string | null> => {
|
||||
if (cachedSelfUserId) {
|
||||
return cachedSelfUserId;
|
||||
}
|
||||
try {
|
||||
cachedSelfUserId = await client.getUserId();
|
||||
} catch {
|
||||
cachedSelfUserId = null;
|
||||
}
|
||||
return cachedSelfUserId;
|
||||
};
|
||||
|
||||
const refreshDmCache = async (): Promise<void> => {
|
||||
const now = Date.now();
|
||||
if (now - lastDmUpdateMs < DM_CACHE_TTL_MS) {
|
||||
return;
|
||||
}
|
||||
lastDmUpdateMs = now;
|
||||
try {
|
||||
await client.dms.update();
|
||||
} catch (err) {
|
||||
log(`matrix: dm cache refresh failed (${String(err)})`);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveMemberCount = async (roomId: string): Promise<number | null> => {
|
||||
const cached = memberCountCache.get(roomId);
|
||||
const now = Date.now();
|
||||
if (cached && now - cached.ts < DM_CACHE_TTL_MS) {
|
||||
return cached.count;
|
||||
}
|
||||
try {
|
||||
const members = await client.getJoinedRoomMembers(roomId);
|
||||
const count = members.length;
|
||||
memberCountCache.set(roomId, { count, ts: now });
|
||||
return count;
|
||||
} catch (err) {
|
||||
log(`matrix: dm member count failed room=${roomId} (${String(err)})`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const hasDirectFlag = async (roomId: string, userId?: string): Promise<boolean> => {
|
||||
const target = userId?.trim();
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const state = await client.getRoomStateEvent(roomId, "m.room.member", target);
|
||||
return state?.is_direct === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isDirectMessage: async (params: DirectMessageCheck): Promise<boolean> => {
|
||||
const { roomId, senderId } = params;
|
||||
await refreshDmCache();
|
||||
|
||||
if (client.dms.isDm(roomId)) {
|
||||
log(`matrix: dm detected via m.direct room=${roomId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const memberCount = await resolveMemberCount(roomId);
|
||||
if (memberCount === 2) {
|
||||
log(`matrix: dm detected via member count room=${roomId} members=${memberCount}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const selfUserId = params.selfUserId ?? (await ensureSelfUserId());
|
||||
const directViaState =
|
||||
(await hasDirectFlag(roomId, senderId)) || (await hasDirectFlag(roomId, selfUserId ?? ""));
|
||||
if (directViaState) {
|
||||
log(`matrix: dm detected via member state room=${roomId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
log(`matrix: dm check room=${roomId} result=group members=${memberCount ?? "unknown"}`);
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
208
extensions/matrix-js/src/matrix/monitor/events.test.ts
Normal file
208
extensions/matrix-js/src/matrix/monitor/events.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixAuth } from "../client.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { registerMatrixMonitorEvents } from "./events.js";
|
||||
import type { MatrixRawEvent } from "./types.js";
|
||||
import { EventType } from "./types.js";
|
||||
|
||||
type RoomEventListener = (roomId: string, event: MatrixRawEvent) => void;
|
||||
|
||||
function getSentNoticeBody(sendMessage: ReturnType<typeof vi.fn>, index = 0): string {
|
||||
const calls = sendMessage.mock.calls as unknown[][];
|
||||
const payload = (calls[index]?.[1] ?? {}) as { body?: string };
|
||||
return payload.body ?? "";
|
||||
}
|
||||
|
||||
function createHarness(params?: {
|
||||
verifications?: Array<{
|
||||
id: string;
|
||||
transactionId?: string;
|
||||
roomId?: string;
|
||||
otherUserId: string;
|
||||
phaseName: string;
|
||||
updatedAt?: string;
|
||||
completed?: boolean;
|
||||
sas?: {
|
||||
decimal?: [number, number, number];
|
||||
emoji?: Array<[string, string]>;
|
||||
};
|
||||
}>;
|
||||
}) {
|
||||
const listeners = new Map<string, (...args: unknown[]) => void>();
|
||||
const onRoomMessage = vi.fn(async () => {});
|
||||
const listVerifications = vi.fn(async () => params?.verifications ?? []);
|
||||
const sendMessage = vi.fn(async () => "$notice");
|
||||
const client = {
|
||||
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
|
||||
listeners.set(eventName, listener);
|
||||
return client;
|
||||
}),
|
||||
sendMessage,
|
||||
crypto: {
|
||||
listVerifications,
|
||||
},
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
registerMatrixMonitorEvents({
|
||||
client,
|
||||
auth: { encryption: true } as MatrixAuth,
|
||||
logVerboseMessage: vi.fn(),
|
||||
warnedEncryptedRooms: new Set<string>(),
|
||||
warnedCryptoMissingRooms: new Set<string>(),
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
formatNativeDependencyHint: vi.fn(() => "install hint"),
|
||||
onRoomMessage,
|
||||
});
|
||||
|
||||
const roomEventListener = listeners.get("room.event") as RoomEventListener | undefined;
|
||||
if (!roomEventListener) {
|
||||
throw new Error("room.event listener was not registered");
|
||||
}
|
||||
|
||||
return {
|
||||
onRoomMessage,
|
||||
sendMessage,
|
||||
roomEventListener,
|
||||
listVerifications,
|
||||
roomMessageListener: listeners.get("room.message") as RoomEventListener | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("registerMatrixMonitorEvents verification routing", () => {
|
||||
it("posts verification request notices directly into the room", async () => {
|
||||
const { onRoomMessage, sendMessage, roomMessageListener } = createHarness();
|
||||
if (!roomMessageListener) {
|
||||
throw new Error("room.message listener was not registered");
|
||||
}
|
||||
roomMessageListener("!room:example.org", {
|
||||
event_id: "$req1",
|
||||
sender: "@alice:example.org",
|
||||
type: EventType.RoomMessage,
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.key.verification.request",
|
||||
body: "verification request",
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(onRoomMessage).not.toHaveBeenCalled();
|
||||
const body = getSentNoticeBody(sendMessage, 0);
|
||||
expect(body).toContain("Matrix verification request received from @alice:example.org.");
|
||||
expect(body).toContain('Open "Verify by emoji"');
|
||||
});
|
||||
|
||||
it("posts ready-stage guidance for emoji verification", async () => {
|
||||
const { sendMessage, roomEventListener } = createHarness();
|
||||
roomEventListener("!room:example.org", {
|
||||
event_id: "$ready-1",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.key.verification.ready",
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
"m.relates_to": { event_id: "$req-ready-1" },
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const body = getSentNoticeBody(sendMessage, 0);
|
||||
expect(body).toContain("Matrix verification is ready with @alice:example.org.");
|
||||
expect(body).toContain('Choose "Verify by emoji"');
|
||||
});
|
||||
|
||||
it("posts SAS emoji/decimal details when verification summaries expose them", async () => {
|
||||
const { sendMessage, roomEventListener, listVerifications } = createHarness({
|
||||
verifications: [
|
||||
{
|
||||
id: "verification-1",
|
||||
transactionId: "$different-flow-id",
|
||||
updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(),
|
||||
otherUserId: "@alice:example.org",
|
||||
phaseName: "started",
|
||||
sas: {
|
||||
decimal: [6158, 1986, 3513],
|
||||
emoji: [
|
||||
["🎁", "Gift"],
|
||||
["🌍", "Globe"],
|
||||
["🐴", "Horse"],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
roomEventListener("!room:example.org", {
|
||||
event_id: "$start2",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.key.verification.start",
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
"m.relates_to": { event_id: "$req2" },
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const bodies = (sendMessage.mock.calls as unknown[][]).map((call) =>
|
||||
String((call[1] as { body?: string } | undefined)?.body ?? ""),
|
||||
);
|
||||
expect(bodies.some((body) => body.includes("SAS emoji:"))).toBe(true);
|
||||
expect(bodies.some((body) => body.includes("SAS decimal: 6158 1986 3513"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not emit duplicate SAS notices for the same verification payload", async () => {
|
||||
const { sendMessage, roomEventListener, listVerifications } = createHarness({
|
||||
verifications: [
|
||||
{
|
||||
id: "verification-3",
|
||||
transactionId: "$req3",
|
||||
otherUserId: "@alice:example.org",
|
||||
phaseName: "started",
|
||||
sas: {
|
||||
decimal: [1111, 2222, 3333],
|
||||
emoji: [
|
||||
["🚀", "Rocket"],
|
||||
["🦋", "Butterfly"],
|
||||
["📕", "Book"],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
roomEventListener("!room:example.org", {
|
||||
event_id: "$start3",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.key.verification.start",
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
"m.relates_to": { event_id: "$req3" },
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(sendMessage.mock.calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
roomEventListener("!room:example.org", {
|
||||
event_id: "$key3",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.key.verification.key",
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
"m.relates_to": { event_id: "$req3" },
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(listVerifications).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
const sasBodies = sendMessage.mock.calls
|
||||
.map((call) => String(((call as unknown[])[1] as { body?: string } | undefined)?.body ?? ""))
|
||||
.filter((body) => body.includes("SAS emoji:"));
|
||||
expect(sasBodies).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
396
extensions/matrix-js/src/matrix/monitor/events.ts
Normal file
396
extensions/matrix-js/src/matrix/monitor/events.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/matrix-js";
|
||||
import type { MatrixAuth } from "../client.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import type { MatrixRawEvent } from "./types.js";
|
||||
import { EventType } from "./types.js";
|
||||
import {
|
||||
isMatrixVerificationEventType,
|
||||
isMatrixVerificationRequestMsgType,
|
||||
matrixVerificationConstants,
|
||||
} from "./verification-utils.js";
|
||||
|
||||
const MAX_TRACKED_VERIFICATION_EVENTS = 1024;
|
||||
|
||||
type MatrixVerificationStage = "request" | "ready" | "start" | "cancel" | "done" | "other";
|
||||
|
||||
type MatrixVerificationSummaryLike = {
|
||||
id: string;
|
||||
transactionId?: string;
|
||||
roomId?: string;
|
||||
otherUserId: string;
|
||||
phaseName: string;
|
||||
updatedAt?: string;
|
||||
completed?: boolean;
|
||||
sas?: {
|
||||
decimal?: [number, number, number];
|
||||
emoji?: Array<[string, string]>;
|
||||
};
|
||||
};
|
||||
|
||||
function trimMaybeString(input: unknown): string | null {
|
||||
if (typeof input !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = input.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function readVerificationSignal(event: MatrixRawEvent): {
|
||||
stage: MatrixVerificationStage;
|
||||
flowId: string | null;
|
||||
} | null {
|
||||
const type = trimMaybeString(event?.type) ?? "";
|
||||
const content = event?.content ?? {};
|
||||
const msgtype = trimMaybeString((content as { msgtype?: unknown }).msgtype) ?? "";
|
||||
const relatedEventId = trimMaybeString(
|
||||
(content as { "m.relates_to"?: { event_id?: unknown } })["m.relates_to"]?.event_id,
|
||||
);
|
||||
const transactionId = trimMaybeString((content as { transaction_id?: unknown }).transaction_id);
|
||||
if (type === EventType.RoomMessage && isMatrixVerificationRequestMsgType(msgtype)) {
|
||||
return {
|
||||
stage: "request",
|
||||
flowId: trimMaybeString(event.event_id) ?? transactionId ?? relatedEventId,
|
||||
};
|
||||
}
|
||||
if (!isMatrixVerificationEventType(type)) {
|
||||
return null;
|
||||
}
|
||||
const flowId = transactionId ?? relatedEventId ?? trimMaybeString(event.event_id);
|
||||
if (type === `${matrixVerificationConstants.eventPrefix}request`) {
|
||||
return { stage: "request", flowId };
|
||||
}
|
||||
if (type === `${matrixVerificationConstants.eventPrefix}ready`) {
|
||||
return { stage: "ready", flowId };
|
||||
}
|
||||
if (type === "m.key.verification.start") {
|
||||
return { stage: "start", flowId };
|
||||
}
|
||||
if (type === "m.key.verification.cancel") {
|
||||
return { stage: "cancel", flowId };
|
||||
}
|
||||
if (type === "m.key.verification.done") {
|
||||
return { stage: "done", flowId };
|
||||
}
|
||||
return { stage: "other", flowId };
|
||||
}
|
||||
|
||||
function formatVerificationStageNotice(params: {
|
||||
stage: MatrixVerificationStage;
|
||||
senderId: string;
|
||||
event: MatrixRawEvent;
|
||||
}): string | null {
|
||||
const { stage, senderId, event } = params;
|
||||
const content = event.content as { code?: unknown; reason?: unknown };
|
||||
switch (stage) {
|
||||
case "request":
|
||||
return `Matrix verification request received from ${senderId}. Open "Verify by emoji" in your Matrix client to continue.`;
|
||||
case "ready":
|
||||
return `Matrix verification is ready with ${senderId}. Choose "Verify by emoji" to reveal the emoji sequence.`;
|
||||
case "start":
|
||||
return `Matrix verification started with ${senderId}.`;
|
||||
case "done":
|
||||
return `Matrix verification completed with ${senderId}.`;
|
||||
case "cancel": {
|
||||
const code = trimMaybeString(content.code);
|
||||
const reason = trimMaybeString(content.reason);
|
||||
if (code && reason) {
|
||||
return `Matrix verification cancelled by ${senderId} (${code}: ${reason}).`;
|
||||
}
|
||||
if (reason) {
|
||||
return `Matrix verification cancelled by ${senderId} (${reason}).`;
|
||||
}
|
||||
return `Matrix verification cancelled by ${senderId}.`;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatVerificationSasNotice(summary: MatrixVerificationSummaryLike): string | null {
|
||||
const sas = summary.sas;
|
||||
if (!sas) {
|
||||
return null;
|
||||
}
|
||||
const emojiLine =
|
||||
Array.isArray(sas.emoji) && sas.emoji.length > 0
|
||||
? `SAS emoji: ${sas.emoji
|
||||
.map(
|
||||
([emoji, name]) => `${trimMaybeString(emoji) ?? "?"} ${trimMaybeString(name) ?? "?"}`,
|
||||
)
|
||||
.join(" | ")}`
|
||||
: null;
|
||||
const decimalLine =
|
||||
Array.isArray(sas.decimal) && sas.decimal.length === 3
|
||||
? `SAS decimal: ${sas.decimal.join(" ")}`
|
||||
: null;
|
||||
if (!emojiLine && !decimalLine) {
|
||||
return null;
|
||||
}
|
||||
const lines = [`Matrix verification SAS with ${summary.otherUserId}:`];
|
||||
if (emojiLine) {
|
||||
lines.push(emojiLine);
|
||||
}
|
||||
if (decimalLine) {
|
||||
lines.push(decimalLine);
|
||||
}
|
||||
lines.push("If both sides match, choose 'They match' in your Matrix app.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function resolveVerificationFlowCandidates(params: {
|
||||
event: MatrixRawEvent;
|
||||
flowId: string | null;
|
||||
}): string[] {
|
||||
const { event, flowId } = params;
|
||||
const content = event.content as {
|
||||
transaction_id?: unknown;
|
||||
"m.relates_to"?: { event_id?: unknown };
|
||||
};
|
||||
const candidates = new Set<string>();
|
||||
const add = (value: unknown) => {
|
||||
const normalized = trimMaybeString(value);
|
||||
if (normalized) {
|
||||
candidates.add(normalized);
|
||||
}
|
||||
};
|
||||
add(flowId);
|
||||
add(event.event_id);
|
||||
add(content.transaction_id);
|
||||
add(content["m.relates_to"]?.event_id);
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
function resolveSummaryRecency(summary: MatrixVerificationSummaryLike): number {
|
||||
const ts = Date.parse(summary.updatedAt ?? "");
|
||||
return Number.isFinite(ts) ? ts : 0;
|
||||
}
|
||||
|
||||
async function resolveVerificationSummaryForSignal(
|
||||
client: MatrixClient,
|
||||
params: {
|
||||
event: MatrixRawEvent;
|
||||
senderId: string;
|
||||
flowId: string | null;
|
||||
},
|
||||
): Promise<MatrixVerificationSummaryLike | null> {
|
||||
if (!client.crypto) {
|
||||
return null;
|
||||
}
|
||||
const list = await client.crypto.listVerifications();
|
||||
if (list.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const candidates = resolveVerificationFlowCandidates({
|
||||
event: params.event,
|
||||
flowId: params.flowId,
|
||||
});
|
||||
const byTransactionId = list.find((entry) =>
|
||||
candidates.some((candidate) => entry.transactionId === candidate),
|
||||
);
|
||||
if (byTransactionId) {
|
||||
return byTransactionId;
|
||||
}
|
||||
|
||||
// Fallback for flows where transaction IDs do not match room event IDs consistently.
|
||||
const byUser = list
|
||||
.filter((entry) => entry.otherUserId === params.senderId && entry.completed !== true)
|
||||
.sort((a, b) => resolveSummaryRecency(b) - resolveSummaryRecency(a))[0];
|
||||
return byUser ?? null;
|
||||
}
|
||||
|
||||
function trackBounded(set: Set<string>, value: string): boolean {
|
||||
if (!value || set.has(value)) {
|
||||
return false;
|
||||
}
|
||||
set.add(value);
|
||||
if (set.size > MAX_TRACKED_VERIFICATION_EVENTS) {
|
||||
const oldest = set.values().next().value;
|
||||
if (typeof oldest === "string") {
|
||||
set.delete(oldest);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendVerificationNotice(params: {
|
||||
client: MatrixClient;
|
||||
roomId: string;
|
||||
body: string;
|
||||
logVerboseMessage: (message: string) => void;
|
||||
}): Promise<void> {
|
||||
const roomId = trimMaybeString(params.roomId);
|
||||
if (!roomId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await params.client.sendMessage(roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: params.body,
|
||||
});
|
||||
} catch (err) {
|
||||
params.logVerboseMessage(
|
||||
`matrix: failed sending verification notice room=${roomId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerMatrixMonitorEvents(params: {
|
||||
client: MatrixClient;
|
||||
auth: MatrixAuth;
|
||||
logVerboseMessage: (message: string) => void;
|
||||
warnedEncryptedRooms: Set<string>;
|
||||
warnedCryptoMissingRooms: Set<string>;
|
||||
logger: RuntimeLogger;
|
||||
formatNativeDependencyHint: PluginRuntime["system"]["formatNativeDependencyHint"];
|
||||
onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise<void>;
|
||||
}): void {
|
||||
const {
|
||||
client,
|
||||
auth,
|
||||
logVerboseMessage,
|
||||
warnedEncryptedRooms,
|
||||
warnedCryptoMissingRooms,
|
||||
logger,
|
||||
formatNativeDependencyHint,
|
||||
onRoomMessage,
|
||||
} = params;
|
||||
const routedVerificationEvents = new Set<string>();
|
||||
const routedVerificationSasFingerprints = new Set<string>();
|
||||
|
||||
const routeVerificationEvent = (roomId: string, event: MatrixRawEvent): boolean => {
|
||||
const senderId = trimMaybeString(event?.sender);
|
||||
if (!senderId) {
|
||||
return false;
|
||||
}
|
||||
const signal = readVerificationSignal(event);
|
||||
if (!signal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const flowId = signal.flowId;
|
||||
const sourceEventId = trimMaybeString(event?.event_id);
|
||||
const sourceFingerprint = sourceEventId ?? `${senderId}:${event.type}:${flowId ?? "none"}`;
|
||||
if (!trackBounded(routedVerificationEvents, sourceFingerprint)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stageNotice = formatVerificationStageNotice({ stage: signal.stage, senderId, event });
|
||||
const summary = await resolveVerificationSummaryForSignal(client, {
|
||||
event,
|
||||
senderId,
|
||||
flowId,
|
||||
}).catch(() => null);
|
||||
const sasNotice = summary ? formatVerificationSasNotice(summary) : null;
|
||||
|
||||
const notices: string[] = [];
|
||||
if (stageNotice) {
|
||||
notices.push(stageNotice);
|
||||
}
|
||||
if (summary && sasNotice) {
|
||||
const sasFingerprint = `${summary.id}:${JSON.stringify(summary.sas)}`;
|
||||
if (trackBounded(routedVerificationSasFingerprints, sasFingerprint)) {
|
||||
notices.push(sasNotice);
|
||||
}
|
||||
}
|
||||
if (notices.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const body of notices) {
|
||||
await sendVerificationNotice({
|
||||
client,
|
||||
roomId,
|
||||
body,
|
||||
logVerboseMessage,
|
||||
});
|
||||
}
|
||||
})().catch((err) => {
|
||||
logVerboseMessage(`matrix: failed routing verification event: ${String(err)}`);
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
client.on("room.message", (roomId: string, event: MatrixRawEvent) => {
|
||||
if (routeVerificationEvent(roomId, event)) {
|
||||
return;
|
||||
}
|
||||
void onRoomMessage(roomId, event);
|
||||
});
|
||||
|
||||
client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => {
|
||||
const eventId = event?.event_id ?? "unknown";
|
||||
const eventType = event?.type ?? "unknown";
|
||||
logVerboseMessage(`matrix: encrypted event room=${roomId} type=${eventType} id=${eventId}`);
|
||||
});
|
||||
|
||||
client.on("room.decrypted_event", (roomId: string, event: MatrixRawEvent) => {
|
||||
const eventId = event?.event_id ?? "unknown";
|
||||
const eventType = event?.type ?? "unknown";
|
||||
logVerboseMessage(`matrix: decrypted event room=${roomId} type=${eventType} id=${eventId}`);
|
||||
});
|
||||
|
||||
client.on(
|
||||
"room.failed_decryption",
|
||||
async (roomId: string, event: MatrixRawEvent, error: Error) => {
|
||||
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}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
client.on("room.invite", (roomId: string, event: MatrixRawEvent) => {
|
||||
const eventId = event?.event_id ?? "unknown";
|
||||
const sender = event?.sender ?? "unknown";
|
||||
const isDirect = (event?.content as { is_direct?: boolean } | undefined)?.is_direct === true;
|
||||
logVerboseMessage(
|
||||
`matrix: invite room=${roomId} sender=${sender} direct=${String(isDirect)} id=${eventId}`,
|
||||
);
|
||||
});
|
||||
|
||||
client.on("room.join", (roomId: string, event: MatrixRawEvent) => {
|
||||
const eventId = event?.event_id ?? "unknown";
|
||||
logVerboseMessage(`matrix: join room=${roomId} id=${eventId}`);
|
||||
});
|
||||
|
||||
client.on("room.event", (roomId: string, event: MatrixRawEvent) => {
|
||||
const eventType = event?.type ?? "unknown";
|
||||
if (eventType === EventType.RoomMessageEncrypted) {
|
||||
logVerboseMessage(
|
||||
`matrix: encrypted raw event room=${roomId} id=${event?.event_id ?? "unknown"}`,
|
||||
);
|
||||
if (auth.encryption !== true && !warnedEncryptedRooms.has(roomId)) {
|
||||
warnedEncryptedRooms.add(roomId);
|
||||
const warning =
|
||||
"matrix: encrypted event received without encryption enabled; set channels.matrix-js.encryption=true and verify the device to decrypt";
|
||||
logger.warn(warning, { roomId });
|
||||
}
|
||||
if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) {
|
||||
warnedCryptoMissingRooms.add(roomId);
|
||||
const hint = formatNativeDependencyHint({
|
||||
packageName: "@matrix-org/matrix-sdk-crypto-nodejs",
|
||||
manager: "pnpm",
|
||||
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(warning, { roomId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (eventType === EventType.RoomMember) {
|
||||
const membership = (event?.content as { membership?: string } | undefined)?.membership;
|
||||
const stateKey = (event as { state_key?: string }).state_key ?? "";
|
||||
logVerboseMessage(
|
||||
`matrix: member event room=${roomId} stateKey=${stateKey} membership=${membership ?? "unknown"}`,
|
||||
);
|
||||
}
|
||||
|
||||
routeVerificationEvent(roomId, event);
|
||||
});
|
||||
}
|
||||
308
extensions/matrix-js/src/matrix/monitor/handler.test.ts
Normal file
308
extensions/matrix-js/src/matrix/monitor/handler.test.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createMatrixRoomMessageHandler } from "./handler.js";
|
||||
import { EventType, type MatrixRawEvent } from "./types.js";
|
||||
|
||||
const sendMessageMatrixMock = vi.hoisted(() =>
|
||||
vi.fn(async (..._args: unknown[]) => ({ messageId: "evt", roomId: "!room" })),
|
||||
);
|
||||
|
||||
vi.mock("../send.js", () => ({
|
||||
reactMatrixMessage: vi.fn(async () => {}),
|
||||
sendMessageMatrix: sendMessageMatrixMock,
|
||||
sendReadReceiptMatrix: vi.fn(async () => {}),
|
||||
sendTypingMatrix: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
describe("matrix monitor handler pairing account scope", () => {
|
||||
it("caches account-scoped allowFrom store reads on hot path", async () => {
|
||||
const readAllowFromStore = vi.fn(async () => [] as string[]);
|
||||
const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false }));
|
||||
sendMessageMatrixMock.mockClear();
|
||||
|
||||
const handler = createMatrixRoomMessageHandler({
|
||||
client: {
|
||||
getUserId: async () => "@bot:example.org",
|
||||
} as never,
|
||||
core: {
|
||||
channel: {
|
||||
pairing: {
|
||||
readAllowFromStore,
|
||||
upsertPairingRequest,
|
||||
buildPairingReply: () => "pairing",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
cfg: {} as never,
|
||||
accountId: "ops",
|
||||
runtime: {} as never,
|
||||
logger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
} as never,
|
||||
logVerboseMessage: () => {},
|
||||
allowFrom: [],
|
||||
mentionRegexes: [],
|
||||
groupPolicy: "open",
|
||||
replyToMode: "off",
|
||||
threadReplies: "inbound",
|
||||
dmEnabled: true,
|
||||
dmPolicy: "pairing",
|
||||
textLimit: 8_000,
|
||||
mediaMaxBytes: 10_000_000,
|
||||
startupMs: 0,
|
||||
startupGraceMs: 0,
|
||||
directTracker: {
|
||||
isDirectMessage: async () => true,
|
||||
},
|
||||
getRoomInfo: async () => ({ altAliases: [] }),
|
||||
getMemberDisplayName: async () => "sender",
|
||||
});
|
||||
|
||||
await handler("!room:example.org", {
|
||||
type: EventType.RoomMessage,
|
||||
sender: "@user:example.org",
|
||||
event_id: "$event1",
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
"m.mentions": { room: true },
|
||||
},
|
||||
} as MatrixRawEvent);
|
||||
|
||||
await handler("!room:example.org", {
|
||||
type: EventType.RoomMessage,
|
||||
sender: "@user:example.org",
|
||||
event_id: "$event2",
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello again",
|
||||
"m.mentions": { room: true },
|
||||
},
|
||||
} as MatrixRawEvent);
|
||||
|
||||
expect(readAllowFromStore).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sends pairing reminders for pending requests with cooldown", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-01T10:00:00.000Z"));
|
||||
try {
|
||||
const readAllowFromStore = vi.fn(async () => [] as string[]);
|
||||
const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false }));
|
||||
sendMessageMatrixMock.mockClear();
|
||||
|
||||
const handler = createMatrixRoomMessageHandler({
|
||||
client: {
|
||||
getUserId: async () => "@bot:example.org",
|
||||
} as never,
|
||||
core: {
|
||||
channel: {
|
||||
pairing: {
|
||||
readAllowFromStore,
|
||||
upsertPairingRequest,
|
||||
buildPairingReply: () => "Pairing code: ABCDEFGH",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
cfg: {} as never,
|
||||
accountId: "ops",
|
||||
runtime: {} as never,
|
||||
logger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
} as never,
|
||||
logVerboseMessage: () => {},
|
||||
allowFrom: [],
|
||||
mentionRegexes: [],
|
||||
groupPolicy: "open",
|
||||
replyToMode: "off",
|
||||
threadReplies: "inbound",
|
||||
dmEnabled: true,
|
||||
dmPolicy: "pairing",
|
||||
textLimit: 8_000,
|
||||
mediaMaxBytes: 10_000_000,
|
||||
startupMs: 0,
|
||||
startupGraceMs: 0,
|
||||
directTracker: {
|
||||
isDirectMessage: async () => true,
|
||||
},
|
||||
getRoomInfo: async () => ({ altAliases: [] }),
|
||||
getMemberDisplayName: async () => "sender",
|
||||
});
|
||||
|
||||
const makeEvent = (id: string): MatrixRawEvent =>
|
||||
({
|
||||
type: EventType.RoomMessage,
|
||||
sender: "@user:example.org",
|
||||
event_id: id,
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
"m.mentions": { room: true },
|
||||
},
|
||||
}) as MatrixRawEvent;
|
||||
|
||||
await handler("!room:example.org", makeEvent("$event1"));
|
||||
await handler("!room:example.org", makeEvent("$event2"));
|
||||
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(sendMessageMatrixMock.mock.calls[0]?.[1] ?? "")).toContain(
|
||||
"Pairing request is still pending approval.",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5 * 60_000 + 1);
|
||||
await handler("!room:example.org", makeEvent("$event3"));
|
||||
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses account-scoped pairing store reads and upserts for dm pairing", async () => {
|
||||
const readAllowFromStore = vi.fn(async () => [] as string[]);
|
||||
const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false }));
|
||||
|
||||
const handler = createMatrixRoomMessageHandler({
|
||||
client: {
|
||||
getUserId: async () => "@bot:example.org",
|
||||
} as never,
|
||||
core: {
|
||||
channel: {
|
||||
pairing: {
|
||||
readAllowFromStore,
|
||||
upsertPairingRequest,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
cfg: {} as never,
|
||||
accountId: "ops",
|
||||
runtime: {} as never,
|
||||
logger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
} as never,
|
||||
logVerboseMessage: () => {},
|
||||
allowFrom: [],
|
||||
mentionRegexes: [],
|
||||
groupPolicy: "open",
|
||||
replyToMode: "off",
|
||||
threadReplies: "inbound",
|
||||
dmEnabled: true,
|
||||
dmPolicy: "pairing",
|
||||
textLimit: 8_000,
|
||||
mediaMaxBytes: 10_000_000,
|
||||
startupMs: 0,
|
||||
startupGraceMs: 0,
|
||||
directTracker: {
|
||||
isDirectMessage: async () => true,
|
||||
},
|
||||
getRoomInfo: async () => ({ altAliases: [] }),
|
||||
getMemberDisplayName: async () => "sender",
|
||||
});
|
||||
|
||||
await handler("!room:example.org", {
|
||||
type: EventType.RoomMessage,
|
||||
sender: "@user:example.org",
|
||||
event_id: "$event1",
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
"m.mentions": { room: true },
|
||||
},
|
||||
} as MatrixRawEvent);
|
||||
|
||||
expect(readAllowFromStore).toHaveBeenCalledWith({
|
||||
channel: "matrix-js",
|
||||
env: process.env,
|
||||
accountId: "ops",
|
||||
});
|
||||
expect(upsertPairingRequest).toHaveBeenCalledWith({
|
||||
channel: "matrix-js",
|
||||
id: "@user:example.org",
|
||||
accountId: "ops",
|
||||
meta: { name: "sender" },
|
||||
});
|
||||
});
|
||||
|
||||
it("passes accountId into route resolution for inbound dm messages", async () => {
|
||||
const resolveAgentRoute = vi.fn(() => ({
|
||||
agentId: "ops",
|
||||
channel: "matrix-js",
|
||||
accountId: "ops",
|
||||
sessionKey: "agent:ops:main",
|
||||
mainSessionKey: "agent:ops:main",
|
||||
matchedBy: "binding.account",
|
||||
}));
|
||||
|
||||
const handler = createMatrixRoomMessageHandler({
|
||||
client: {
|
||||
getUserId: async () => "@bot:example.org",
|
||||
} as never,
|
||||
core: {
|
||||
channel: {
|
||||
pairing: {
|
||||
readAllowFromStore: async () => [] as string[],
|
||||
upsertPairingRequest: async () => ({ code: "ABCDEFGH", created: false }),
|
||||
},
|
||||
commands: {
|
||||
shouldHandleTextCommands: () => false,
|
||||
},
|
||||
text: {
|
||||
hasControlCommand: () => false,
|
||||
},
|
||||
routing: {
|
||||
resolveAgentRoute,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
cfg: {} as never,
|
||||
accountId: "ops",
|
||||
runtime: {
|
||||
error: () => {},
|
||||
} as never,
|
||||
logger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
} as never,
|
||||
logVerboseMessage: () => {},
|
||||
allowFrom: [],
|
||||
mentionRegexes: [],
|
||||
groupPolicy: "open",
|
||||
replyToMode: "off",
|
||||
threadReplies: "inbound",
|
||||
dmEnabled: true,
|
||||
dmPolicy: "open",
|
||||
textLimit: 8_000,
|
||||
mediaMaxBytes: 10_000_000,
|
||||
startupMs: 0,
|
||||
startupGraceMs: 0,
|
||||
directTracker: {
|
||||
isDirectMessage: async () => true,
|
||||
},
|
||||
getRoomInfo: async () => ({ altAliases: [] }),
|
||||
getMemberDisplayName: async () => "sender",
|
||||
});
|
||||
|
||||
await handler("!room:example.org", {
|
||||
type: EventType.RoomMessage,
|
||||
sender: "@user:example.org",
|
||||
event_id: "$event2",
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
"m.mentions": { room: true },
|
||||
},
|
||||
} as MatrixRawEvent);
|
||||
|
||||
expect(resolveAgentRoute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "matrix-js",
|
||||
accountId: "ops",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
713
extensions/matrix-js/src/matrix/monitor/handler.ts
Normal file
713
extensions/matrix-js/src/matrix/monitor/handler.ts
Normal file
@@ -0,0 +1,713 @@
|
||||
import {
|
||||
createReplyPrefixOptions,
|
||||
createTypingCallbacks,
|
||||
formatAllowlistMatchMeta,
|
||||
logInboundDrop,
|
||||
logTypingFailure,
|
||||
resolveControlCommandGate,
|
||||
type PluginRuntime,
|
||||
type ReplyPayload,
|
||||
type RuntimeEnv,
|
||||
type RuntimeLogger,
|
||||
} from "openclaw/plugin-sdk/matrix-js";
|
||||
import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js";
|
||||
import {
|
||||
formatPollAsText,
|
||||
isPollStartType,
|
||||
parsePollStartContent,
|
||||
type PollStartContent,
|
||||
} from "../poll-types.js";
|
||||
import type { LocationMessageEventContent, MatrixClient } from "../sdk.js";
|
||||
import {
|
||||
reactMatrixMessage,
|
||||
sendMessageMatrix,
|
||||
sendReadReceiptMatrix,
|
||||
sendTypingMatrix,
|
||||
} from "../send.js";
|
||||
import {
|
||||
normalizeMatrixAllowList,
|
||||
resolveMatrixAllowListMatch,
|
||||
resolveMatrixAllowListMatches,
|
||||
} from "./allowlist.js";
|
||||
import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js";
|
||||
import { downloadMatrixMedia } from "./media.js";
|
||||
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";
|
||||
import { isMatrixVerificationRoomMessage } from "./verification-utils.js";
|
||||
|
||||
const ALLOW_FROM_STORE_CACHE_TTL_MS = 30_000;
|
||||
const PAIRING_REPLY_COOLDOWN_MS = 5 * 60_000;
|
||||
const MAX_TRACKED_PAIRING_REPLY_SENDERS = 512;
|
||||
|
||||
export type MatrixMonitorHandlerParams = {
|
||||
client: MatrixClient;
|
||||
core: PluginRuntime;
|
||||
cfg: CoreConfig;
|
||||
accountId: string;
|
||||
runtime: RuntimeEnv;
|
||||
logger: RuntimeLogger;
|
||||
logVerboseMessage: (message: string) => void;
|
||||
allowFrom: string[];
|
||||
roomsConfig?: Record<string, MatrixRoomConfig>;
|
||||
mentionRegexes: ReturnType<PluginRuntime["channel"]["mentions"]["buildMentionRegexes"]>;
|
||||
groupPolicy: "open" | "allowlist" | "disabled";
|
||||
replyToMode: ReplyToMode;
|
||||
threadReplies: "off" | "inbound" | "always";
|
||||
dmEnabled: boolean;
|
||||
dmPolicy: "open" | "pairing" | "allowlist" | "disabled";
|
||||
textLimit: number;
|
||||
mediaMaxBytes: number;
|
||||
startupMs: number;
|
||||
startupGraceMs: number;
|
||||
directTracker: {
|
||||
isDirectMessage: (params: {
|
||||
roomId: string;
|
||||
senderId: string;
|
||||
selfUserId: string;
|
||||
}) => Promise<boolean>;
|
||||
};
|
||||
getRoomInfo: (
|
||||
roomId: string,
|
||||
) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>;
|
||||
getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
|
||||
};
|
||||
|
||||
export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) {
|
||||
const {
|
||||
client,
|
||||
core,
|
||||
cfg,
|
||||
accountId,
|
||||
runtime,
|
||||
logger,
|
||||
logVerboseMessage,
|
||||
allowFrom,
|
||||
roomsConfig,
|
||||
mentionRegexes,
|
||||
groupPolicy,
|
||||
replyToMode,
|
||||
threadReplies,
|
||||
dmEnabled,
|
||||
dmPolicy,
|
||||
textLimit,
|
||||
mediaMaxBytes,
|
||||
startupMs,
|
||||
startupGraceMs,
|
||||
directTracker,
|
||||
getRoomInfo,
|
||||
getMemberDisplayName,
|
||||
} = params;
|
||||
let cachedStoreAllowFrom: {
|
||||
value: string[];
|
||||
expiresAtMs: number;
|
||||
} | null = null;
|
||||
const pairingReplySentAtMsBySender = new Map<string, number>();
|
||||
|
||||
const readStoreAllowFrom = async (): Promise<string[]> => {
|
||||
const now = Date.now();
|
||||
if (cachedStoreAllowFrom && now < cachedStoreAllowFrom.expiresAtMs) {
|
||||
return cachedStoreAllowFrom.value;
|
||||
}
|
||||
const value = await core.channel.pairing
|
||||
.readAllowFromStore({
|
||||
channel: "matrix-js",
|
||||
env: process.env,
|
||||
accountId,
|
||||
})
|
||||
.catch(() => []);
|
||||
cachedStoreAllowFrom = {
|
||||
value,
|
||||
expiresAtMs: now + ALLOW_FROM_STORE_CACHE_TTL_MS,
|
||||
};
|
||||
return value;
|
||||
};
|
||||
|
||||
const shouldSendPairingReply = (senderId: string, created: boolean): boolean => {
|
||||
const now = Date.now();
|
||||
if (created) {
|
||||
pairingReplySentAtMsBySender.set(senderId, now);
|
||||
return true;
|
||||
}
|
||||
const lastSentAtMs = pairingReplySentAtMsBySender.get(senderId);
|
||||
if (typeof lastSentAtMs === "number" && now - lastSentAtMs < PAIRING_REPLY_COOLDOWN_MS) {
|
||||
return false;
|
||||
}
|
||||
pairingReplySentAtMsBySender.set(senderId, now);
|
||||
if (pairingReplySentAtMsBySender.size > MAX_TRACKED_PAIRING_REPLY_SENDERS) {
|
||||
const oldestSender = pairingReplySentAtMsBySender.keys().next().value;
|
||||
if (typeof oldestSender === "string") {
|
||||
pairingReplySentAtMsBySender.delete(oldestSender);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return async (roomId: string, event: MatrixRawEvent) => {
|
||||
try {
|
||||
const eventType = event.type;
|
||||
if (eventType === EventType.RoomMessageEncrypted) {
|
||||
// Encrypted payloads are emitted separately after decryption.
|
||||
return;
|
||||
}
|
||||
|
||||
const isPollEvent = isPollStartType(eventType);
|
||||
const locationContent = event.content as LocationMessageEventContent;
|
||||
const isLocationEvent =
|
||||
eventType === EventType.Location ||
|
||||
(eventType === EventType.RoomMessage && locationContent.msgtype === EventType.Location);
|
||||
if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) {
|
||||
return;
|
||||
}
|
||||
logVerboseMessage(
|
||||
`matrix: room.message recv room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`,
|
||||
);
|
||||
if (event.unsigned?.redacted_because) {
|
||||
return;
|
||||
}
|
||||
const senderId = event.sender;
|
||||
if (!senderId) {
|
||||
return;
|
||||
}
|
||||
const selfUserId = await client.getUserId();
|
||||
if (senderId === selfUserId) {
|
||||
return;
|
||||
}
|
||||
const eventTs = event.origin_server_ts;
|
||||
const eventAge = event.unsigned?.age;
|
||||
if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof eventTs !== "number" &&
|
||||
typeof eventAge === "number" &&
|
||||
eventAge > startupGraceMs
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const roomInfo = await getRoomInfo(roomId);
|
||||
const roomName = roomInfo.name;
|
||||
const roomAliases = [roomInfo.canonicalAlias ?? "", ...roomInfo.altAliases].filter(Boolean);
|
||||
|
||||
let content = event.content as RoomMessageEventContent;
|
||||
if (isPollEvent) {
|
||||
const pollStartContent = event.content as PollStartContent;
|
||||
const pollSummary = parsePollStartContent(pollStartContent);
|
||||
if (pollSummary) {
|
||||
pollSummary.eventId = event.event_id ?? "";
|
||||
pollSummary.roomId = roomId;
|
||||
pollSummary.sender = senderId;
|
||||
const senderDisplayName = await getMemberDisplayName(roomId, senderId);
|
||||
pollSummary.senderName = senderDisplayName;
|
||||
const pollText = formatPollAsText(pollSummary);
|
||||
content = {
|
||||
msgtype: "m.text",
|
||||
body: pollText,
|
||||
} as unknown as RoomMessageEventContent;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
eventType === EventType.RoomMessage &&
|
||||
isMatrixVerificationRoomMessage({
|
||||
msgtype: (content as { msgtype?: unknown }).msgtype,
|
||||
body: content.body,
|
||||
})
|
||||
) {
|
||||
logVerboseMessage(`matrix: skip verification/system room message room=${roomId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const locationPayload: MatrixLocationPayload | null = resolveMatrixLocation({
|
||||
eventType,
|
||||
content: content as LocationMessageEventContent,
|
||||
});
|
||||
|
||||
const relates = content["m.relates_to"];
|
||||
if (relates && "rel_type" in relates) {
|
||||
if (relates.rel_type === RelationType.Replace) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const isDirectMessage = await directTracker.isDirectMessage({
|
||||
roomId,
|
||||
senderId,
|
||||
selfUserId,
|
||||
});
|
||||
const isRoom = !isDirectMessage;
|
||||
|
||||
if (isRoom && groupPolicy === "disabled") {
|
||||
return;
|
||||
}
|
||||
|
||||
const roomConfigInfo = isRoom
|
||||
? resolveMatrixRoomConfig({
|
||||
rooms: roomsConfig,
|
||||
roomId,
|
||||
aliases: roomAliases,
|
||||
name: roomName,
|
||||
})
|
||||
: undefined;
|
||||
const roomConfig = roomConfigInfo?.config;
|
||||
const roomMatchMeta = roomConfigInfo
|
||||
? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${
|
||||
roomConfigInfo.matchSource ?? "none"
|
||||
}`
|
||||
: "matchKey=none matchSource=none";
|
||||
|
||||
if (isRoom && roomConfig && !roomConfigInfo?.allowed) {
|
||||
logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
|
||||
return;
|
||||
}
|
||||
if (isRoom && groupPolicy === "allowlist") {
|
||||
if (!roomConfigInfo?.allowlistConfigured) {
|
||||
logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
|
||||
return;
|
||||
}
|
||||
if (!roomConfig) {
|
||||
logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const senderName = await getMemberDisplayName(roomId, senderId);
|
||||
const storeAllowFrom = await readStoreAllowFrom();
|
||||
const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]);
|
||||
const groupAllowFrom = cfg.channels?.["matrix-js"]?.groupAllowFrom ?? [];
|
||||
const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom);
|
||||
const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
|
||||
|
||||
if (isDirectMessage) {
|
||||
if (!dmEnabled || dmPolicy === "disabled") {
|
||||
return;
|
||||
}
|
||||
if (dmPolicy !== "open") {
|
||||
const allowMatch = resolveMatrixAllowListMatch({
|
||||
allowList: effectiveAllowFrom,
|
||||
userId: senderId,
|
||||
});
|
||||
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
||||
if (!allowMatch.allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||
channel: "matrix-js",
|
||||
id: senderId,
|
||||
accountId,
|
||||
meta: { name: senderName },
|
||||
});
|
||||
if (shouldSendPairingReply(senderId, created)) {
|
||||
const pairingReply = core.channel.pairing.buildPairingReply({
|
||||
channel: "matrix-js",
|
||||
idLine: `Your Matrix user id: ${senderId}`,
|
||||
code,
|
||||
});
|
||||
logVerboseMessage(
|
||||
created
|
||||
? `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`
|
||||
: `matrix pairing reminder sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
|
||||
);
|
||||
try {
|
||||
await sendMessageMatrix(
|
||||
`room:${roomId}`,
|
||||
created
|
||||
? pairingReply
|
||||
: `${pairingReply}\n\nPairing request is still pending approval. Reusing existing code.`,
|
||||
{ client },
|
||||
);
|
||||
} catch (err) {
|
||||
logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
|
||||
}
|
||||
} else {
|
||||
logVerboseMessage(
|
||||
`matrix pairing reminder suppressed sender=${senderId} (cooldown)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (dmPolicy !== "pairing") {
|
||||
logVerboseMessage(
|
||||
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const roomUsers = roomConfig?.users ?? [];
|
||||
if (isRoom && roomUsers.length > 0) {
|
||||
const userMatch = resolveMatrixAllowListMatch({
|
||||
allowList: normalizeMatrixAllowList(roomUsers),
|
||||
userId: senderId,
|
||||
});
|
||||
if (!userMatch.allowed) {
|
||||
logVerboseMessage(
|
||||
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
|
||||
userMatch,
|
||||
)})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) {
|
||||
const groupAllowMatch = resolveMatrixAllowListMatch({
|
||||
allowList: effectiveGroupAllowFrom,
|
||||
userId: senderId,
|
||||
});
|
||||
if (!groupAllowMatch.allowed) {
|
||||
logVerboseMessage(
|
||||
`matrix: blocked sender ${senderId} (groupAllowFrom, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
|
||||
groupAllowMatch,
|
||||
)})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isRoom) {
|
||||
logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
|
||||
}
|
||||
|
||||
const rawBody =
|
||||
locationPayload?.text ?? (typeof content.body === "string" ? content.body.trim() : "");
|
||||
let media: {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
} | null = null;
|
||||
const contentUrl =
|
||||
"url" in content && typeof content.url === "string" ? content.url : undefined;
|
||||
const contentFile =
|
||||
"file" in content && content.file && typeof content.file === "object"
|
||||
? content.file
|
||||
: undefined;
|
||||
const mediaUrl = contentUrl ?? contentFile?.url;
|
||||
if (!rawBody && !mediaUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentInfo =
|
||||
"info" in content && content.info && typeof content.info === "object"
|
||||
? (content.info as { mimetype?: string; size?: number })
|
||||
: undefined;
|
||||
const contentType = contentInfo?.mimetype;
|
||||
const contentSize = typeof contentInfo?.size === "number" ? contentInfo.size : undefined;
|
||||
if (mediaUrl?.startsWith("mxc://")) {
|
||||
try {
|
||||
media = await downloadMatrixMedia({
|
||||
client,
|
||||
mxcUrl: mediaUrl,
|
||||
contentType,
|
||||
sizeBytes: contentSize,
|
||||
maxBytes: mediaMaxBytes,
|
||||
file: contentFile,
|
||||
});
|
||||
} catch (err) {
|
||||
logVerboseMessage(`matrix: media download failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const bodyText = rawBody || media?.placeholder || "";
|
||||
if (!bodyText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { wasMentioned, hasExplicitMention } = resolveMentions({
|
||||
content,
|
||||
userId: selfUserId,
|
||||
text: bodyText,
|
||||
mentionRegexes,
|
||||
});
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: "matrix-js",
|
||||
});
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const senderAllowedForCommands = resolveMatrixAllowListMatches({
|
||||
allowList: effectiveAllowFrom,
|
||||
userId: senderId,
|
||||
});
|
||||
const senderAllowedForGroup = groupAllowConfigured
|
||||
? resolveMatrixAllowListMatches({
|
||||
allowList: effectiveGroupAllowFrom,
|
||||
userId: senderId,
|
||||
})
|
||||
: false;
|
||||
const senderAllowedForRoomUsers =
|
||||
isRoom && roomUsers.length > 0
|
||||
? resolveMatrixAllowListMatches({
|
||||
allowList: normalizeMatrixAllowList(roomUsers),
|
||||
userId: senderId,
|
||||
})
|
||||
: false;
|
||||
const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg);
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||
{ configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers },
|
||||
{ configured: groupAllowConfigured, allowed: senderAllowedForGroup },
|
||||
],
|
||||
allowTextCommands,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
});
|
||||
const commandAuthorized = commandGate.commandAuthorized;
|
||||
if (isRoom && commandGate.shouldBlock) {
|
||||
logInboundDrop({
|
||||
log: logVerboseMessage,
|
||||
channel: "matrix-js",
|
||||
reason: "control command (unauthorized)",
|
||||
target: senderId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const shouldRequireMention = isRoom
|
||||
? roomConfig?.autoReply === true
|
||||
? false
|
||||
: roomConfig?.autoReply === false
|
||||
? true
|
||||
: typeof roomConfig?.requireMention === "boolean"
|
||||
? roomConfig?.requireMention
|
||||
: true
|
||||
: false;
|
||||
const shouldBypassMention =
|
||||
allowTextCommands &&
|
||||
isRoom &&
|
||||
shouldRequireMention &&
|
||||
!wasMentioned &&
|
||||
!hasExplicitMention &&
|
||||
commandAuthorized &&
|
||||
hasControlCommandInMessage;
|
||||
const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention;
|
||||
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
|
||||
logger.info("skipping room message", { roomId, reason: "no-mention" });
|
||||
return;
|
||||
}
|
||||
|
||||
const messageId = event.event_id ?? "";
|
||||
const replyToEventId = content["m.relates_to"]?.["m.in_reply_to"]?.event_id;
|
||||
const threadRootId = resolveMatrixThreadRootId({ event, content });
|
||||
const threadTarget = resolveMatrixThreadTarget({
|
||||
threadReplies,
|
||||
messageId,
|
||||
threadRootId,
|
||||
isThreadRoot: false, // Raw event payload does not carry explicit thread-root metadata.
|
||||
});
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "matrix-js",
|
||||
accountId,
|
||||
peer: {
|
||||
kind: isDirectMessage ? "direct" : "channel",
|
||||
id: isDirectMessage ? senderId : roomId,
|
||||
},
|
||||
});
|
||||
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
||||
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Matrix",
|
||||
from: envelopeFrom,
|
||||
timestamp: eventTs ?? undefined,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: textWithId,
|
||||
});
|
||||
|
||||
const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined;
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: bodyText,
|
||||
CommandBody: bodyText,
|
||||
From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`,
|
||||
To: `room:${roomId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isDirectMessage ? "direct" : "channel",
|
||||
ConversationLabel: envelopeFrom,
|
||||
SenderName: senderName,
|
||||
SenderId: senderId,
|
||||
SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""),
|
||||
GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
|
||||
GroupChannel: isRoom ? (roomInfo.canonicalAlias ?? roomId) : undefined,
|
||||
GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
|
||||
Provider: "matrix-js" as const,
|
||||
Surface: "matrix-js" as const,
|
||||
WasMentioned: isRoom ? wasMentioned : undefined,
|
||||
MessageSid: messageId,
|
||||
ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined),
|
||||
MessageThreadId: threadTarget,
|
||||
Timestamp: eventTs ?? undefined,
|
||||
MediaPath: media?.path,
|
||||
MediaType: media?.contentType,
|
||||
MediaUrl: media?.path,
|
||||
...locationPayload?.context,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
CommandSource: "text" as const,
|
||||
OriginatingChannel: "matrix-js" as const,
|
||||
OriginatingTo: `room:${roomId}`,
|
||||
});
|
||||
|
||||
await core.channel.session.recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
updateLastRoute: isDirectMessage
|
||||
? {
|
||||
sessionKey: route.mainSessionKey,
|
||||
channel: "matrix-js",
|
||||
to: `room:${roomId}`,
|
||||
accountId: route.accountId,
|
||||
}
|
||||
: undefined,
|
||||
onRecordError: (err) => {
|
||||
logger.warn("failed updating session meta", {
|
||||
error: String(err),
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
||||
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
|
||||
|
||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
||||
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||
const shouldAckReaction = () =>
|
||||
Boolean(
|
||||
ackReaction &&
|
||||
core.channel.reactions.shouldAckReaction({
|
||||
scope: ackScope,
|
||||
isDirect: isDirectMessage,
|
||||
isGroup: isRoom,
|
||||
isMentionableGroup: isRoom,
|
||||
requireMention: Boolean(shouldRequireMention),
|
||||
canDetectMention,
|
||||
effectiveWasMentioned: wasMentioned || shouldBypassMention,
|
||||
shouldBypassMention,
|
||||
}),
|
||||
);
|
||||
if (shouldAckReaction() && messageId) {
|
||||
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
|
||||
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
|
||||
const replyTarget = ctxPayload.To;
|
||||
if (!replyTarget) {
|
||||
runtime.error?.("matrix: missing reply target");
|
||||
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,
|
||||
channel: "matrix-js",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "matrix-js",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
const typingCallbacks = createTypingCallbacks({
|
||||
start: () => sendTypingMatrix(roomId, true, undefined, client),
|
||||
stop: () => sendTypingMatrix(roomId, false, undefined, client),
|
||||
onStartError: (err) => {
|
||||
logTypingFailure({
|
||||
log: logVerboseMessage,
|
||||
channel: "matrix-js",
|
||||
action: "start",
|
||||
target: roomId,
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
onStopError: (err) => {
|
||||
logTypingFailure({
|
||||
log: logVerboseMessage,
|
||||
channel: "matrix-js",
|
||||
action: "stop",
|
||||
target: roomId,
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
...prefixOptions,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
await deliverMatrixReplies({
|
||||
replies: [payload],
|
||||
roomId,
|
||||
client,
|
||||
runtime,
|
||||
textLimit,
|
||||
replyToMode,
|
||||
threadId: threadTarget,
|
||||
accountId: route.accountId,
|
||||
tableMode,
|
||||
});
|
||||
didSendReply = true;
|
||||
},
|
||||
onError: (err: unknown, info: { kind: "tool" | "block" | "final" }) => {
|
||||
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
onReplyStart: typingCallbacks.onReplyStart,
|
||||
onIdle: typingCallbacks.onIdle,
|
||||
});
|
||||
|
||||
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
skillFilter: roomConfig?.skills,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
markDispatchIdle();
|
||||
if (!queuedFinal) {
|
||||
return;
|
||||
}
|
||||
didSendReply = true;
|
||||
const finalCount = counts.final;
|
||||
logVerboseMessage(
|
||||
`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
|
||||
);
|
||||
if (didSendReply) {
|
||||
const previewText = bodyText.replace(/\s+/g, " ").slice(0, 160);
|
||||
core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${previewText}`, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.error?.(`matrix handler failed: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
400
extensions/matrix-js/src/matrix/monitor/index.ts
Normal file
400
extensions/matrix-js/src/matrix/monitor/index.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import { format } from "node:util";
|
||||
import {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
mergeAllowlist,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
summarizeMapping,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
type RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk/matrix-js";
|
||||
import { resolveMatrixTargets } from "../../resolve-targets.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig, ReplyToMode } from "../../types.js";
|
||||
import { resolveMatrixAccount } from "../accounts.js";
|
||||
import { setActiveMatrixClient } from "../active-client.js";
|
||||
import {
|
||||
isBunRuntime,
|
||||
resolveMatrixAuth,
|
||||
resolveSharedMatrixClient,
|
||||
stopSharedClientForAccount,
|
||||
} from "../client.js";
|
||||
import { updateMatrixAccountConfig } from "../config-update.js";
|
||||
import { syncMatrixOwnProfile } from "../profile.js";
|
||||
import { normalizeMatrixUserId } from "./allowlist.js";
|
||||
import { registerMatrixAutoJoin } from "./auto-join.js";
|
||||
import { createDirectRoomTracker } from "./direct.js";
|
||||
import { registerMatrixMonitorEvents } from "./events.js";
|
||||
import { createMatrixRoomMessageHandler } from "./handler.js";
|
||||
import { createMatrixRoomInfoResolver } from "./room-info.js";
|
||||
|
||||
export type MonitorMatrixOpts = {
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
mediaMaxMb?: number;
|
||||
initialSyncLimit?: number;
|
||||
replyToMode?: ReplyToMode;
|
||||
accountId?: string | null;
|
||||
};
|
||||
|
||||
const DEFAULT_MEDIA_MAX_MB = 20;
|
||||
|
||||
export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promise<void> {
|
||||
if (isBunRuntime()) {
|
||||
throw new Error("Matrix provider requires Node (bun runtime not supported)");
|
||||
}
|
||||
const core = getMatrixRuntime();
|
||||
let cfg = core.config.loadConfig() as CoreConfig;
|
||||
if (cfg.channels?.["matrix-js"]?.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 logVerboseMessage = (message: string) => {
|
||||
if (!core.logging.shouldLogVerbose()) {
|
||||
return;
|
||||
}
|
||||
logger.debug?.(message);
|
||||
};
|
||||
|
||||
const normalizeUserEntry = (raw: string) =>
|
||||
raw
|
||||
.replace(/^matrix:/i, "")
|
||||
.replace(/^user:/i, "")
|
||||
.trim();
|
||||
const normalizeRoomEntry = (raw: string) =>
|
||||
raw
|
||||
.replace(/^matrix:/i, "")
|
||||
.replace(/^(room|channel):/i, "")
|
||||
.trim();
|
||||
const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":");
|
||||
const resolveUserAllowlist = async (
|
||||
label: string,
|
||||
list?: Array<string | number>,
|
||||
): Promise<string[]> => {
|
||||
let allowList = list ?? [];
|
||||
if (allowList.length === 0) {
|
||||
return allowList.map(String);
|
||||
}
|
||||
const entries = allowList
|
||||
.map((entry) => normalizeUserEntry(String(entry)))
|
||||
.filter((entry) => entry && entry !== "*");
|
||||
if (entries.length === 0) {
|
||||
return allowList.map(String);
|
||||
}
|
||||
const mapping: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
const additions: string[] = [];
|
||||
const pending: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (isMatrixUserId(entry)) {
|
||||
additions.push(normalizeMatrixUserId(entry));
|
||||
continue;
|
||||
}
|
||||
pending.push(entry);
|
||||
}
|
||||
if (pending.length > 0) {
|
||||
const resolved = await resolveMatrixTargets({
|
||||
cfg,
|
||||
inputs: pending,
|
||||
kind: "user",
|
||||
runtime,
|
||||
});
|
||||
for (const entry of resolved) {
|
||||
if (entry.resolved && entry.id) {
|
||||
const normalizedId = normalizeMatrixUserId(entry.id);
|
||||
additions.push(normalizedId);
|
||||
mapping.push(`${entry.input}→${normalizedId}`);
|
||||
} else {
|
||||
unresolved.push(entry.input);
|
||||
}
|
||||
}
|
||||
}
|
||||
allowList = mergeAllowlist({ existing: allowList, additions });
|
||||
summarizeMapping(label, mapping, unresolved, runtime);
|
||||
if (unresolved.length > 0) {
|
||||
runtime.log?.(
|
||||
`${label} entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.`,
|
||||
);
|
||||
}
|
||||
return allowList.map(String);
|
||||
};
|
||||
|
||||
// Resolve account-specific config for multi-account support
|
||||
const account = resolveMatrixAccount({ cfg, accountId: opts.accountId });
|
||||
const accountConfig = account.config;
|
||||
|
||||
const allowlistOnly = accountConfig.allowlistOnly === true;
|
||||
let allowFrom: string[] = (accountConfig.dm?.allowFrom ?? []).map(String);
|
||||
let groupAllowFrom: string[] = (accountConfig.groupAllowFrom ?? []).map(String);
|
||||
let roomsConfig = accountConfig.groups ?? accountConfig.rooms;
|
||||
|
||||
allowFrom = await resolveUserAllowlist("matrix dm allowlist", allowFrom);
|
||||
groupAllowFrom = await resolveUserAllowlist("matrix group allowlist", groupAllowFrom);
|
||||
|
||||
if (roomsConfig && Object.keys(roomsConfig).length > 0) {
|
||||
const mapping: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
const nextRooms: Record<string, (typeof roomsConfig)[string]> = {};
|
||||
if (roomsConfig["*"]) {
|
||||
nextRooms["*"] = roomsConfig["*"];
|
||||
}
|
||||
const pending: Array<{ input: string; query: string; config: (typeof roomsConfig)[string] }> =
|
||||
[];
|
||||
for (const [entry, roomConfig] of Object.entries(roomsConfig)) {
|
||||
if (entry === "*") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const cleaned = normalizeRoomEntry(trimmed);
|
||||
if ((cleaned.startsWith("!") || cleaned.startsWith("#")) && cleaned.includes(":")) {
|
||||
if (!nextRooms[cleaned]) {
|
||||
nextRooms[cleaned] = roomConfig;
|
||||
}
|
||||
if (cleaned !== entry) {
|
||||
mapping.push(`${entry}→${cleaned}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
pending.push({ input: entry, query: trimmed, config: roomConfig });
|
||||
}
|
||||
if (pending.length > 0) {
|
||||
const resolved = await resolveMatrixTargets({
|
||||
cfg,
|
||||
inputs: pending.map((entry) => entry.query),
|
||||
kind: "group",
|
||||
runtime,
|
||||
});
|
||||
resolved.forEach((entry, index) => {
|
||||
const source = pending[index];
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
if (entry.resolved && entry.id) {
|
||||
if (!nextRooms[entry.id]) {
|
||||
nextRooms[entry.id] = source.config;
|
||||
}
|
||||
mapping.push(`${source.input}→${entry.id}`);
|
||||
} else {
|
||||
unresolved.push(source.input);
|
||||
}
|
||||
});
|
||||
}
|
||||
roomsConfig = nextRooms;
|
||||
summarizeMapping("matrix rooms", mapping, unresolved, runtime);
|
||||
if (unresolved.length > 0) {
|
||||
runtime.log?.(
|
||||
"matrix rooms must be room IDs or aliases (example: !room:server or #alias:server). Unresolved entries are ignored.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (roomsConfig && Object.keys(roomsConfig).length > 0) {
|
||||
const nextRooms = { ...roomsConfig };
|
||||
for (const [roomKey, roomConfig] of Object.entries(roomsConfig)) {
|
||||
const users = roomConfig?.users ?? [];
|
||||
if (users.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const resolvedUsers = await resolveUserAllowlist(`matrix room users (${roomKey})`, users);
|
||||
if (resolvedUsers !== users) {
|
||||
nextRooms[roomKey] = { ...roomConfig, users: resolvedUsers };
|
||||
}
|
||||
}
|
||||
roomsConfig = nextRooms;
|
||||
}
|
||||
|
||||
cfg = {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
"matrix-js": {
|
||||
...cfg.channels?.["matrix-js"],
|
||||
dm: {
|
||||
...cfg.channels?.["matrix-js"]?.dm,
|
||||
allowFrom,
|
||||
},
|
||||
groupAllowFrom,
|
||||
...(roomsConfig ? { groups: roomsConfig } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const auth = await resolveMatrixAuth({ cfg, accountId: opts.accountId });
|
||||
const resolvedInitialSyncLimit =
|
||||
typeof opts.initialSyncLimit === "number"
|
||||
? Math.max(0, Math.floor(opts.initialSyncLimit))
|
||||
: auth.initialSyncLimit;
|
||||
const authWithLimit =
|
||||
resolvedInitialSyncLimit === auth.initialSyncLimit
|
||||
? auth
|
||||
: { ...auth, initialSyncLimit: resolvedInitialSyncLimit };
|
||||
const client = await resolveSharedMatrixClient({
|
||||
cfg,
|
||||
auth: authWithLimit,
|
||||
startClient: false,
|
||||
accountId: opts.accountId ?? undefined,
|
||||
});
|
||||
setActiveMatrixClient(client, opts.accountId);
|
||||
|
||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
||||
const { groupPolicy: groupPolicyRaw, providerMissingFallbackApplied } =
|
||||
resolveAllowlistProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: cfg.channels?.["matrix-js"] !== undefined,
|
||||
groupPolicy: accountConfig.groupPolicy,
|
||||
defaultGroupPolicy,
|
||||
});
|
||||
warnMissingProviderGroupPolicyFallbackOnce({
|
||||
providerMissingFallbackApplied,
|
||||
providerKey: "matrix-js",
|
||||
accountId: account.accountId,
|
||||
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room,
|
||||
log: (message) => logVerboseMessage(message),
|
||||
});
|
||||
const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw;
|
||||
const replyToMode = opts.replyToMode ?? accountConfig.replyToMode ?? "off";
|
||||
const threadReplies = accountConfig.threadReplies ?? "inbound";
|
||||
const dmConfig = accountConfig.dm;
|
||||
const dmEnabled = dmConfig?.enabled ?? true;
|
||||
const dmPolicyRaw = dmConfig?.policy ?? "pairing";
|
||||
const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw;
|
||||
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix-js");
|
||||
const mediaMaxMb = opts.mediaMaxMb ?? accountConfig.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
||||
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
|
||||
const startupMs = Date.now();
|
||||
const startupGraceMs = 0;
|
||||
const directTracker = createDirectRoomTracker(client, { log: logVerboseMessage });
|
||||
registerMatrixAutoJoin({ client, cfg, runtime });
|
||||
const warnedEncryptedRooms = new Set<string>();
|
||||
const warnedCryptoMissingRooms = new Set<string>();
|
||||
|
||||
const { getRoomInfo, getMemberDisplayName } = createMatrixRoomInfoResolver(client);
|
||||
const handleRoomMessage = createMatrixRoomMessageHandler({
|
||||
client,
|
||||
core,
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
logger,
|
||||
logVerboseMessage,
|
||||
allowFrom,
|
||||
roomsConfig,
|
||||
mentionRegexes,
|
||||
groupPolicy,
|
||||
replyToMode,
|
||||
threadReplies,
|
||||
dmEnabled,
|
||||
dmPolicy,
|
||||
textLimit,
|
||||
mediaMaxBytes,
|
||||
startupMs,
|
||||
startupGraceMs,
|
||||
directTracker,
|
||||
getRoomInfo,
|
||||
getMemberDisplayName,
|
||||
});
|
||||
|
||||
registerMatrixMonitorEvents({
|
||||
client,
|
||||
auth,
|
||||
logVerboseMessage,
|
||||
warnedEncryptedRooms,
|
||||
warnedCryptoMissingRooms,
|
||||
logger,
|
||||
formatNativeDependencyHint: core.system.formatNativeDependencyHint,
|
||||
onRoomMessage: handleRoomMessage,
|
||||
});
|
||||
|
||||
logVerboseMessage("matrix: starting client");
|
||||
await resolveSharedMatrixClient({
|
||||
cfg,
|
||||
auth: authWithLimit,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
logVerboseMessage("matrix: client started");
|
||||
|
||||
// Shared client is already started via resolveSharedMatrixClient.
|
||||
logger.info(`matrix: logged in as ${auth.userId}`);
|
||||
|
||||
try {
|
||||
const profileSync = await syncMatrixOwnProfile({
|
||||
client,
|
||||
userId: auth.userId,
|
||||
displayName: accountConfig.name,
|
||||
avatarUrl: accountConfig.avatarUrl,
|
||||
loadAvatarFromUrl: async (url, maxBytes) => await core.media.loadWebMedia(url, maxBytes),
|
||||
});
|
||||
if (profileSync.displayNameUpdated) {
|
||||
logger.info(`matrix: profile display name updated for ${auth.userId}`);
|
||||
}
|
||||
if (profileSync.avatarUpdated) {
|
||||
logger.info(`matrix: profile avatar updated for ${auth.userId}`);
|
||||
}
|
||||
if (
|
||||
profileSync.convertedAvatarFromHttp &&
|
||||
profileSync.resolvedAvatarUrl &&
|
||||
accountConfig.avatarUrl !== profileSync.resolvedAvatarUrl
|
||||
) {
|
||||
const latestCfg = core.config.loadConfig() as CoreConfig;
|
||||
const updatedCfg = updateMatrixAccountConfig(latestCfg, account.accountId, {
|
||||
avatarUrl: profileSync.resolvedAvatarUrl,
|
||||
});
|
||||
await core.config.writeConfigFile(updatedCfg as never);
|
||||
logVerboseMessage(
|
||||
`matrix: persisted converted avatar URL for account ${account.accountId} (${profileSync.resolvedAvatarUrl})`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("matrix: failed to sync profile from config", { error: String(err) });
|
||||
}
|
||||
|
||||
// If E2EE is enabled, report device verification status and guidance.
|
||||
if (auth.encryption && client.crypto) {
|
||||
try {
|
||||
const status = await client.getOwnDeviceVerificationStatus();
|
||||
if (status.verified) {
|
||||
logger.info("matrix: device is verified and ready for encrypted rooms");
|
||||
} else {
|
||||
logger.info(
|
||||
"matrix: device not verified — run 'openclaw matrix-js verify device <key>' to enable E2EE",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug?.("Failed to resolve matrix-js verification status (non-fatal)", {
|
||||
error: String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const onAbort = () => {
|
||||
try {
|
||||
logVerboseMessage("matrix: stopping client");
|
||||
stopSharedClientForAccount(auth, opts.accountId);
|
||||
} finally {
|
||||
setActiveMatrixClient(null, opts.accountId);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
if (opts.abortSignal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
100
extensions/matrix-js/src/matrix/monitor/location.ts
Normal file
100
extensions/matrix-js/src/matrix/monitor/location.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
formatLocationText,
|
||||
toLocationContext,
|
||||
type NormalizedLocation,
|
||||
} from "openclaw/plugin-sdk/matrix-js";
|
||||
import type { LocationMessageEventContent } from "../sdk.js";
|
||||
import { EventType } from "./types.js";
|
||||
|
||||
export type MatrixLocationPayload = {
|
||||
text: string;
|
||||
context: ReturnType<typeof toLocationContext>;
|
||||
};
|
||||
|
||||
type GeoUriParams = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy?: number;
|
||||
};
|
||||
|
||||
function parseGeoUri(value: string): GeoUriParams | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (!trimmed.toLowerCase().startsWith("geo:")) {
|
||||
return null;
|
||||
}
|
||||
const payload = trimmed.slice(4);
|
||||
const [coordsPart, ...paramParts] = payload.split(";");
|
||||
const coords = coordsPart.split(",");
|
||||
if (coords.length < 2) {
|
||||
return null;
|
||||
}
|
||||
const latitude = Number.parseFloat(coords[0] ?? "");
|
||||
const longitude = Number.parseFloat(coords[1] ?? "");
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = new Map<string, string>();
|
||||
for (const part of paramParts) {
|
||||
const segment = part.trim();
|
||||
if (!segment) {
|
||||
continue;
|
||||
}
|
||||
const eqIndex = segment.indexOf("=");
|
||||
const rawKey = eqIndex === -1 ? segment : segment.slice(0, eqIndex);
|
||||
const rawValue = eqIndex === -1 ? "" : segment.slice(eqIndex + 1);
|
||||
const key = rawKey.trim().toLowerCase();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const valuePart = rawValue.trim();
|
||||
params.set(key, valuePart ? decodeURIComponent(valuePart) : "");
|
||||
}
|
||||
|
||||
const accuracyRaw = params.get("u");
|
||||
const accuracy = accuracyRaw ? Number.parseFloat(accuracyRaw) : undefined;
|
||||
|
||||
return {
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy: Number.isFinite(accuracy) ? accuracy : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMatrixLocation(params: {
|
||||
eventType: string;
|
||||
content: LocationMessageEventContent;
|
||||
}): MatrixLocationPayload | null {
|
||||
const { eventType, content } = params;
|
||||
const isLocation =
|
||||
eventType === EventType.Location ||
|
||||
(eventType === EventType.RoomMessage && content.msgtype === EventType.Location);
|
||||
if (!isLocation) {
|
||||
return null;
|
||||
}
|
||||
const geoUri = typeof content.geo_uri === "string" ? content.geo_uri.trim() : "";
|
||||
if (!geoUri) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseGeoUri(geoUri);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
const caption = typeof content.body === "string" ? content.body.trim() : "";
|
||||
const location: NormalizedLocation = {
|
||||
latitude: parsed.latitude,
|
||||
longitude: parsed.longitude,
|
||||
accuracy: parsed.accuracy,
|
||||
caption: caption || undefined,
|
||||
source: "pin",
|
||||
isLive: false,
|
||||
};
|
||||
|
||||
return {
|
||||
text: formatLocationText(location),
|
||||
context: toLocationContext(location),
|
||||
};
|
||||
}
|
||||
102
extensions/matrix-js/src/matrix/monitor/media.test.ts
Normal file
102
extensions/matrix-js/src/matrix/monitor/media.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix-js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setMatrixRuntime } from "../../runtime.js";
|
||||
import { downloadMatrixMedia } from "./media.js";
|
||||
|
||||
describe("downloadMatrixMedia", () => {
|
||||
const saveMediaBuffer = vi.fn().mockResolvedValue({
|
||||
path: "/tmp/media",
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
const runtimeStub = {
|
||||
channel: {
|
||||
media: {
|
||||
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setMatrixRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
it("decrypts encrypted media when file payloads are present", 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 result = await downloadMatrixMedia({
|
||||
client,
|
||||
mxcUrl: "mxc://example/file",
|
||||
contentType: "image/png",
|
||||
maxBytes: 1024,
|
||||
file,
|
||||
});
|
||||
|
||||
// decryptMedia should be called with just the file object (it handles download internally)
|
||||
expect(decryptMedia).toHaveBeenCalledWith(file);
|
||||
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
||||
Buffer.from("decrypted"),
|
||||
"image/png",
|
||||
"inbound",
|
||||
1024,
|
||||
);
|
||||
expect(result?.path).toBe("/tmp/media");
|
||||
});
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
await expect(
|
||||
downloadMatrixMedia({
|
||||
client,
|
||||
mxcUrl: "mxc://example/file",
|
||||
contentType: "image/png",
|
||||
sizeBytes: 2048,
|
||||
maxBytes: 1024,
|
||||
file,
|
||||
}),
|
||||
).rejects.toThrow("Matrix media exceeds configured size limit");
|
||||
|
||||
expect(decryptMedia).not.toHaveBeenCalled();
|
||||
expect(saveMediaBuffer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
117
extensions/matrix-js/src/matrix/monitor/media.ts
Normal file
117
extensions/matrix-js/src/matrix/monitor/media.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
|
||||
// Type for encrypted file info
|
||||
type EncryptedFile = {
|
||||
url: string;
|
||||
key: {
|
||||
kty: string;
|
||||
key_ops: string[];
|
||||
alg: string;
|
||||
k: string;
|
||||
ext: boolean;
|
||||
};
|
||||
iv: string;
|
||||
hashes: Record<string, string>;
|
||||
v: string;
|
||||
};
|
||||
|
||||
async function fetchMatrixMediaBuffer(params: {
|
||||
client: MatrixClient;
|
||||
mxcUrl: string;
|
||||
maxBytes: number;
|
||||
}): Promise<{ buffer: Buffer } | null> {
|
||||
// The client wrapper exposes mxcToHttp for Matrix media URIs.
|
||||
const url = params.client.mxcToHttp(params.mxcUrl);
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the client's download method which handles auth
|
||||
try {
|
||||
const raw = await params.client.downloadContent(params.mxcUrl);
|
||||
const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
||||
|
||||
if (buffer.byteLength > params.maxBytes) {
|
||||
throw new Error("Matrix media exceeds configured size limit");
|
||||
}
|
||||
return { buffer };
|
||||
} catch (err) {
|
||||
throw new Error(`Matrix media download failed: ${String(err)}`, { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and decrypt encrypted media from a Matrix room.
|
||||
* Uses the Matrix crypto adapter's decryptMedia helper.
|
||||
*/
|
||||
async function fetchEncryptedMediaBuffer(params: {
|
||||
client: MatrixClient;
|
||||
file: EncryptedFile;
|
||||
maxBytes: number;
|
||||
}): Promise<{ buffer: Buffer } | null> {
|
||||
if (!params.client.crypto) {
|
||||
throw new Error("Cannot decrypt media: crypto not enabled");
|
||||
}
|
||||
|
||||
// decryptMedia handles downloading and decrypting the encrypted content internally
|
||||
const decrypted = await params.client.crypto.decryptMedia(
|
||||
params.file as Parameters<typeof params.client.crypto.decryptMedia>[0],
|
||||
);
|
||||
|
||||
if (decrypted.byteLength > params.maxBytes) {
|
||||
throw new Error("Matrix media exceeds configured size limit");
|
||||
}
|
||||
|
||||
return { buffer: decrypted };
|
||||
}
|
||||
|
||||
export async function downloadMatrixMedia(params: {
|
||||
client: MatrixClient;
|
||||
mxcUrl: string;
|
||||
contentType?: string;
|
||||
sizeBytes?: number;
|
||||
maxBytes: number;
|
||||
file?: EncryptedFile;
|
||||
}): Promise<{
|
||||
path: string;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
} | null> {
|
||||
let fetched: { buffer: Buffer; headerType?: string } | null;
|
||||
if (typeof params.sizeBytes === "number" && params.sizeBytes > params.maxBytes) {
|
||||
throw new Error("Matrix media exceeds configured size limit");
|
||||
}
|
||||
|
||||
if (params.file) {
|
||||
// Encrypted media
|
||||
fetched = await fetchEncryptedMediaBuffer({
|
||||
client: params.client,
|
||||
file: params.file,
|
||||
maxBytes: params.maxBytes,
|
||||
});
|
||||
} else {
|
||||
// Unencrypted media
|
||||
fetched = await fetchMatrixMediaBuffer({
|
||||
client: params.client,
|
||||
mxcUrl: params.mxcUrl,
|
||||
maxBytes: params.maxBytes,
|
||||
});
|
||||
}
|
||||
|
||||
if (!fetched) {
|
||||
return null;
|
||||
}
|
||||
const headerType = params.contentType ?? undefined;
|
||||
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
|
||||
fetched.buffer,
|
||||
headerType,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
);
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "[matrix media]",
|
||||
};
|
||||
}
|
||||
154
extensions/matrix-js/src/matrix/monitor/mentions.test.ts
Normal file
154
extensions/matrix-js/src/matrix/monitor/mentions.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock the runtime before importing resolveMentions
|
||||
vi.mock("../../runtime.js", () => ({
|
||||
getMatrixRuntime: () => ({
|
||||
channel: {
|
||||
mentions: {
|
||||
matchesMentionPatterns: (text: string, patterns: RegExp[]) =>
|
||||
patterns.some((p) => p.test(text)),
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { resolveMentions } from "./mentions.js";
|
||||
|
||||
describe("resolveMentions", () => {
|
||||
const userId = "@bot:matrix.org";
|
||||
const mentionRegexes = [/@bot/i];
|
||||
|
||||
describe("m.mentions field", () => {
|
||||
it("detects mention via m.mentions.user_ids", () => {
|
||||
const result = resolveMentions({
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
"m.mentions": { user_ids: ["@bot:matrix.org"] },
|
||||
},
|
||||
userId,
|
||||
text: "hello",
|
||||
mentionRegexes,
|
||||
});
|
||||
expect(result.wasMentioned).toBe(true);
|
||||
expect(result.hasExplicitMention).toBe(true);
|
||||
});
|
||||
|
||||
it("detects room mention via m.mentions.room", () => {
|
||||
const result = resolveMentions({
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello everyone",
|
||||
"m.mentions": { room: true },
|
||||
},
|
||||
userId,
|
||||
text: "hello everyone",
|
||||
mentionRegexes,
|
||||
});
|
||||
expect(result.wasMentioned).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatted_body matrix.to links", () => {
|
||||
it("detects mention in formatted_body with plain user ID", () => {
|
||||
const result = resolveMentions({
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "Bot: hello",
|
||||
formatted_body: '<a href="https://matrix.to/#/@bot:matrix.org">Bot</a>: hello',
|
||||
},
|
||||
userId,
|
||||
text: "Bot: hello",
|
||||
mentionRegexes: [],
|
||||
});
|
||||
expect(result.wasMentioned).toBe(true);
|
||||
});
|
||||
|
||||
it("detects mention in formatted_body with URL-encoded user ID", () => {
|
||||
const result = resolveMentions({
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "Bot: hello",
|
||||
formatted_body: '<a href="https://matrix.to/#/%40bot%3Amatrix.org">Bot</a>: hello',
|
||||
},
|
||||
userId,
|
||||
text: "Bot: hello",
|
||||
mentionRegexes: [],
|
||||
});
|
||||
expect(result.wasMentioned).toBe(true);
|
||||
});
|
||||
|
||||
it("detects mention with single quotes in href", () => {
|
||||
const result = resolveMentions({
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "Bot: hello",
|
||||
formatted_body: "<a href='https://matrix.to/#/@bot:matrix.org'>Bot</a>: hello",
|
||||
},
|
||||
userId,
|
||||
text: "Bot: hello",
|
||||
mentionRegexes: [],
|
||||
});
|
||||
expect(result.wasMentioned).toBe(true);
|
||||
});
|
||||
|
||||
it("does not detect mention for different user ID", () => {
|
||||
const result = resolveMentions({
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "Other: hello",
|
||||
formatted_body: '<a href="https://matrix.to/#/@other:matrix.org">Other</a>: hello',
|
||||
},
|
||||
userId,
|
||||
text: "Other: hello",
|
||||
mentionRegexes: [],
|
||||
});
|
||||
expect(result.wasMentioned).toBe(false);
|
||||
});
|
||||
|
||||
it("does not false-positive on partial user ID match", () => {
|
||||
const result = resolveMentions({
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "Bot2: hello",
|
||||
formatted_body: '<a href="https://matrix.to/#/@bot2:matrix.org">Bot2</a>: hello',
|
||||
},
|
||||
userId: "@bot:matrix.org",
|
||||
text: "Bot2: hello",
|
||||
mentionRegexes: [],
|
||||
});
|
||||
expect(result.wasMentioned).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("regex patterns", () => {
|
||||
it("detects mention via regex pattern in body text", () => {
|
||||
const result = resolveMentions({
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hey @bot can you help?",
|
||||
},
|
||||
userId,
|
||||
text: "hey @bot can you help?",
|
||||
mentionRegexes,
|
||||
});
|
||||
expect(result.wasMentioned).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no mention", () => {
|
||||
it("returns false when no mention is present", () => {
|
||||
const result = resolveMentions({
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello world",
|
||||
},
|
||||
userId,
|
||||
text: "hello world",
|
||||
mentionRegexes,
|
||||
});
|
||||
expect(result.wasMentioned).toBe(false);
|
||||
expect(result.hasExplicitMention).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
52
extensions/matrix-js/src/matrix/monitor/mentions.ts
Normal file
52
extensions/matrix-js/src/matrix/monitor/mentions.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { RoomMessageEventContent } from "./types.js";
|
||||
|
||||
/**
|
||||
* Check if the formatted_body contains a matrix.to mention link for the given user ID.
|
||||
* Many Matrix clients (including Element) use HTML links in formatted_body instead of
|
||||
* or in addition to the m.mentions field.
|
||||
*/
|
||||
function checkFormattedBodyMention(formattedBody: string | undefined, userId: string): boolean {
|
||||
if (!formattedBody || !userId) {
|
||||
return false;
|
||||
}
|
||||
// Escape special regex characters in the user ID (e.g., @user:matrix.org)
|
||||
const escapedUserId = userId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
// Match matrix.to links with the user ID, handling both URL-encoded and plain formats
|
||||
// Example: href="https://matrix.to/#/@user:matrix.org" or href="https://matrix.to/#/%40user%3Amatrix.org"
|
||||
const plainPattern = new RegExp(`href=["']https://matrix\\.to/#/${escapedUserId}["']`, "i");
|
||||
if (plainPattern.test(formattedBody)) {
|
||||
return true;
|
||||
}
|
||||
// Also check URL-encoded version (@ -> %40, : -> %3A)
|
||||
const encodedUserId = encodeURIComponent(userId).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const encodedPattern = new RegExp(`href=["']https://matrix\\.to/#/${encodedUserId}["']`, "i");
|
||||
return encodedPattern.test(formattedBody);
|
||||
}
|
||||
|
||||
export function resolveMentions(params: {
|
||||
content: RoomMessageEventContent;
|
||||
userId?: string | null;
|
||||
text?: string;
|
||||
mentionRegexes: RegExp[];
|
||||
}) {
|
||||
const mentions = params.content["m.mentions"];
|
||||
const mentionedUsers = Array.isArray(mentions?.user_ids)
|
||||
? new Set(mentions.user_ids)
|
||||
: new Set<string>();
|
||||
|
||||
// Check formatted_body for matrix.to mention links (legacy/alternative mention format)
|
||||
const mentionedInFormattedBody = params.userId
|
||||
? checkFormattedBodyMention(params.content.formatted_body, params.userId)
|
||||
: false;
|
||||
|
||||
const wasMentioned =
|
||||
Boolean(mentions?.room) ||
|
||||
(params.userId ? mentionedUsers.has(params.userId) : false) ||
|
||||
mentionedInFormattedBody ||
|
||||
getMatrixRuntime().channel.mentions.matchesMentionPatterns(
|
||||
params.text ?? "",
|
||||
params.mentionRegexes,
|
||||
);
|
||||
return { wasMentioned, hasExplicitMention: Boolean(mentions) };
|
||||
}
|
||||
132
extensions/matrix-js/src/matrix/monitor/replies.test.ts
Normal file
132
extensions/matrix-js/src/matrix/monitor/replies.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix-js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
|
||||
const sendMessageMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue({ messageId: "mx-1" }));
|
||||
|
||||
vi.mock("../send.js", () => ({
|
||||
sendMessageMatrix: (to: string, message: string, opts?: unknown) =>
|
||||
sendMessageMatrixMock(to, message, opts),
|
||||
}));
|
||||
|
||||
import { setMatrixRuntime } from "../../runtime.js";
|
||||
import { deliverMatrixReplies } from "./replies.js";
|
||||
|
||||
describe("deliverMatrixReplies", () => {
|
||||
const loadConfigMock = vi.fn(() => ({}));
|
||||
const resolveMarkdownTableModeMock = vi.fn(() => "code");
|
||||
const convertMarkdownTablesMock = vi.fn((text: string) => text);
|
||||
const resolveChunkModeMock = vi.fn(() => "length");
|
||||
const chunkMarkdownTextWithModeMock = vi.fn((text: string) => [text]);
|
||||
|
||||
const runtimeStub = {
|
||||
config: {
|
||||
loadConfig: () => loadConfigMock(),
|
||||
},
|
||||
channel: {
|
||||
text: {
|
||||
resolveMarkdownTableMode: () => resolveMarkdownTableModeMock(),
|
||||
convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text),
|
||||
resolveChunkMode: () => resolveChunkModeMock(),
|
||||
chunkMarkdownTextWithMode: (text: string) => chunkMarkdownTextWithModeMock(text),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
const runtimeEnv: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as RuntimeEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setMatrixRuntime(runtimeStub);
|
||||
chunkMarkdownTextWithModeMock.mockImplementation((text: string) => [text]);
|
||||
});
|
||||
|
||||
it("keeps replyToId on first reply only when replyToMode=first", async () => {
|
||||
chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|"));
|
||||
|
||||
await deliverMatrixReplies({
|
||||
replies: [
|
||||
{ text: "first-a|first-b", replyToId: "reply-1" },
|
||||
{ text: "second", replyToId: "reply-2" },
|
||||
],
|
||||
roomId: "room:1",
|
||||
client: {} as MatrixClient,
|
||||
runtime: runtimeEnv,
|
||||
textLimit: 4000,
|
||||
replyToMode: "first",
|
||||
});
|
||||
|
||||
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(3);
|
||||
expect(sendMessageMatrixMock.mock.calls[0]?.[2]).toEqual(
|
||||
expect.objectContaining({ replyToId: "reply-1", threadId: undefined }),
|
||||
);
|
||||
expect(sendMessageMatrixMock.mock.calls[1]?.[2]).toEqual(
|
||||
expect.objectContaining({ replyToId: "reply-1", threadId: undefined }),
|
||||
);
|
||||
expect(sendMessageMatrixMock.mock.calls[2]?.[2]).toEqual(
|
||||
expect.objectContaining({ replyToId: undefined, threadId: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps replyToId on every reply when replyToMode=all", async () => {
|
||||
await deliverMatrixReplies({
|
||||
replies: [
|
||||
{
|
||||
text: "caption",
|
||||
mediaUrls: ["https://example.com/a.jpg", "https://example.com/b.jpg"],
|
||||
replyToId: "reply-media",
|
||||
audioAsVoice: true,
|
||||
},
|
||||
{ text: "plain", replyToId: "reply-text" },
|
||||
],
|
||||
roomId: "room:2",
|
||||
client: {} as MatrixClient,
|
||||
runtime: runtimeEnv,
|
||||
textLimit: 4000,
|
||||
replyToMode: "all",
|
||||
});
|
||||
|
||||
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(3);
|
||||
expect(sendMessageMatrixMock.mock.calls[0]).toEqual([
|
||||
"room:2",
|
||||
"caption",
|
||||
expect.objectContaining({ mediaUrl: "https://example.com/a.jpg", replyToId: "reply-media" }),
|
||||
]);
|
||||
expect(sendMessageMatrixMock.mock.calls[1]).toEqual([
|
||||
"room:2",
|
||||
"",
|
||||
expect.objectContaining({ mediaUrl: "https://example.com/b.jpg", replyToId: "reply-media" }),
|
||||
]);
|
||||
expect(sendMessageMatrixMock.mock.calls[2]?.[2]).toEqual(
|
||||
expect.objectContaining({ replyToId: "reply-text" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("suppresses replyToId when threadId is set", async () => {
|
||||
chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|"));
|
||||
|
||||
await deliverMatrixReplies({
|
||||
replies: [{ text: "hello|thread", replyToId: "reply-thread" }],
|
||||
roomId: "room:3",
|
||||
client: {} as MatrixClient,
|
||||
runtime: runtimeEnv,
|
||||
textLimit: 4000,
|
||||
replyToMode: "all",
|
||||
threadId: "thread-77",
|
||||
});
|
||||
|
||||
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2);
|
||||
expect(sendMessageMatrixMock.mock.calls[0]?.[2]).toEqual(
|
||||
expect.objectContaining({ replyToId: undefined, threadId: "thread-77" }),
|
||||
);
|
||||
expect(sendMessageMatrixMock.mock.calls[1]?.[2]).toEqual(
|
||||
expect.objectContaining({ replyToId: undefined, threadId: "thread-77" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
100
extensions/matrix-js/src/matrix/monitor/replies.ts
Normal file
100
extensions/matrix-js/src/matrix/monitor/replies.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk/matrix-js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { sendMessageMatrix } from "../send.js";
|
||||
|
||||
export async function deliverMatrixReplies(params: {
|
||||
replies: ReplyPayload[];
|
||||
roomId: string;
|
||||
client: MatrixClient;
|
||||
runtime: RuntimeEnv;
|
||||
textLimit: number;
|
||||
replyToMode: "off" | "first" | "all";
|
||||
threadId?: string;
|
||||
accountId?: string;
|
||||
tableMode?: MarkdownTableMode;
|
||||
}): Promise<void> {
|
||||
const core = getMatrixRuntime();
|
||||
const cfg = core.config.loadConfig();
|
||||
const tableMode =
|
||||
params.tableMode ??
|
||||
core.channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "matrix-js",
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const logVerbose = (message: string) => {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
params.runtime.log?.(message);
|
||||
}
|
||||
};
|
||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
||||
const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix-js", params.accountId);
|
||||
let hasReplied = false;
|
||||
for (const reply of params.replies) {
|
||||
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
|
||||
if (!reply?.text && !hasMedia) {
|
||||
if (reply?.audioAsVoice) {
|
||||
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
|
||||
continue;
|
||||
}
|
||||
params.runtime.error?.("matrix reply missing text/media");
|
||||
continue;
|
||||
}
|
||||
const replyToIdRaw = reply.replyToId?.trim();
|
||||
const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw;
|
||||
const rawText = reply.text ?? "";
|
||||
const text = core.channel.text.convertMarkdownTables(rawText, tableMode);
|
||||
const mediaList = reply.mediaUrls?.length
|
||||
? reply.mediaUrls
|
||||
: reply.mediaUrl
|
||||
? [reply.mediaUrl]
|
||||
: [];
|
||||
|
||||
const shouldIncludeReply = (id?: string) =>
|
||||
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
|
||||
const replyToIdForReply = shouldIncludeReply(replyToId) ? replyToId : undefined;
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
let sentTextChunk = false;
|
||||
for (const chunk of core.channel.text.chunkMarkdownTextWithMode(
|
||||
text,
|
||||
chunkLimit,
|
||||
chunkMode,
|
||||
)) {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
await sendMessageMatrix(params.roomId, trimmed, {
|
||||
client: params.client,
|
||||
replyToId: replyToIdForReply,
|
||||
threadId: params.threadId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
sentTextChunk = true;
|
||||
}
|
||||
if (replyToIdForReply && !hasReplied && sentTextChunk) {
|
||||
hasReplied = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaList) {
|
||||
const caption = first ? text : "";
|
||||
await sendMessageMatrix(params.roomId, caption, {
|
||||
client: params.client,
|
||||
mediaUrl,
|
||||
replyToId: replyToIdForReply,
|
||||
threadId: params.threadId,
|
||||
audioAsVoice: reply.audioAsVoice,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
first = false;
|
||||
}
|
||||
if (replyToIdForReply && !hasReplied) {
|
||||
hasReplied = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
65
extensions/matrix-js/src/matrix/monitor/room-info.ts
Normal file
65
extensions/matrix-js/src/matrix/monitor/room-info.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
|
||||
export type MatrixRoomInfo = {
|
||||
name?: string;
|
||||
canonicalAlias?: string;
|
||||
altAliases: string[];
|
||||
};
|
||||
|
||||
export function createMatrixRoomInfoResolver(client: MatrixClient) {
|
||||
const roomInfoCache = new Map<string, MatrixRoomInfo>();
|
||||
|
||||
const getRoomInfo = async (roomId: string): Promise<MatrixRoomInfo> => {
|
||||
const cached = roomInfoCache.get(roomId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
let name: string | undefined;
|
||||
let canonicalAlias: string | undefined;
|
||||
let altAliases: string[] = [];
|
||||
try {
|
||||
const nameState = await client.getRoomStateEvent(roomId, "m.room.name", "").catch(() => null);
|
||||
if (nameState && typeof nameState.name === "string") {
|
||||
name = nameState.name;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
const aliasState = await client
|
||||
.getRoomStateEvent(roomId, "m.room.canonical_alias", "")
|
||||
.catch(() => null);
|
||||
if (aliasState && typeof aliasState.alias === "string") {
|
||||
canonicalAlias = aliasState.alias;
|
||||
}
|
||||
const rawAliases = aliasState?.alt_aliases;
|
||||
if (Array.isArray(rawAliases)) {
|
||||
altAliases = rawAliases.filter((entry): entry is string => typeof entry === "string");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const info = { name, canonicalAlias, altAliases };
|
||||
roomInfoCache.set(roomId, info);
|
||||
return info;
|
||||
};
|
||||
|
||||
const getMemberDisplayName = async (roomId: string, userId: string): Promise<string> => {
|
||||
try {
|
||||
const memberState = await client
|
||||
.getRoomStateEvent(roomId, "m.room.member", userId)
|
||||
.catch(() => null);
|
||||
if (memberState && typeof memberState.displayname === "string") {
|
||||
return memberState.displayname;
|
||||
}
|
||||
return userId;
|
||||
} catch {
|
||||
return userId;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getRoomInfo,
|
||||
getMemberDisplayName,
|
||||
};
|
||||
}
|
||||
39
extensions/matrix-js/src/matrix/monitor/rooms.test.ts
Normal file
39
extensions/matrix-js/src/matrix/monitor/rooms.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveMatrixRoomConfig } from "./rooms.js";
|
||||
|
||||
describe("resolveMatrixRoomConfig", () => {
|
||||
it("matches room IDs and aliases, not names", () => {
|
||||
const rooms = {
|
||||
"!room:example.org": { allow: true },
|
||||
"#alias:example.org": { allow: true },
|
||||
"Project Room": { allow: true },
|
||||
};
|
||||
|
||||
const byId = resolveMatrixRoomConfig({
|
||||
rooms,
|
||||
roomId: "!room:example.org",
|
||||
aliases: [],
|
||||
name: "Project Room",
|
||||
});
|
||||
expect(byId.allowed).toBe(true);
|
||||
expect(byId.matchKey).toBe("!room:example.org");
|
||||
|
||||
const byAlias = resolveMatrixRoomConfig({
|
||||
rooms,
|
||||
roomId: "!other:example.org",
|
||||
aliases: ["#alias:example.org"],
|
||||
name: "Other Room",
|
||||
});
|
||||
expect(byAlias.allowed).toBe(true);
|
||||
expect(byAlias.matchKey).toBe("#alias:example.org");
|
||||
|
||||
const byName = resolveMatrixRoomConfig({
|
||||
rooms: { "Project Room": { allow: true } },
|
||||
roomId: "!different:example.org",
|
||||
aliases: [],
|
||||
name: "Project Room",
|
||||
});
|
||||
expect(byName.allowed).toBe(false);
|
||||
expect(byName.config).toBeUndefined();
|
||||
});
|
||||
});
|
||||
47
extensions/matrix-js/src/matrix/monitor/rooms.ts
Normal file
47
extensions/matrix-js/src/matrix/monitor/rooms.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "openclaw/plugin-sdk/matrix-js";
|
||||
import type { MatrixRoomConfig } from "../../types.js";
|
||||
|
||||
export type MatrixRoomConfigResolved = {
|
||||
allowed: boolean;
|
||||
allowlistConfigured: boolean;
|
||||
config?: MatrixRoomConfig;
|
||||
matchKey?: string;
|
||||
matchSource?: "direct" | "wildcard";
|
||||
};
|
||||
|
||||
export function resolveMatrixRoomConfig(params: {
|
||||
rooms?: Record<string, MatrixRoomConfig>;
|
||||
roomId: string;
|
||||
aliases: string[];
|
||||
name?: string | null;
|
||||
}): MatrixRoomConfigResolved {
|
||||
const rooms = params.rooms ?? {};
|
||||
const keys = Object.keys(rooms);
|
||||
const allowlistConfigured = keys.length > 0;
|
||||
const candidates = buildChannelKeyCandidates(
|
||||
params.roomId,
|
||||
`room:${params.roomId}`,
|
||||
...params.aliases,
|
||||
);
|
||||
const {
|
||||
entry: matched,
|
||||
key: matchedKey,
|
||||
wildcardEntry,
|
||||
wildcardKey,
|
||||
} = resolveChannelEntryMatch({
|
||||
entries: rooms,
|
||||
keys: candidates,
|
||||
wildcardKey: "*",
|
||||
});
|
||||
const resolved = matched ?? wildcardEntry;
|
||||
const allowed = resolved ? resolved.enabled !== false && resolved.allow !== false : false;
|
||||
const matchKey = matchedKey ?? wildcardKey;
|
||||
const matchSource = matched ? "direct" : wildcardEntry ? "wildcard" : undefined;
|
||||
return {
|
||||
allowed,
|
||||
allowlistConfigured,
|
||||
config: resolved,
|
||||
matchKey,
|
||||
matchSource,
|
||||
};
|
||||
}
|
||||
48
extensions/matrix-js/src/matrix/monitor/threads.ts
Normal file
48
extensions/matrix-js/src/matrix/monitor/threads.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
|
||||
import { RelationType } from "./types.js";
|
||||
|
||||
export function resolveMatrixThreadTarget(params: {
|
||||
threadReplies: "off" | "inbound" | "always";
|
||||
messageId: string;
|
||||
threadRootId?: string;
|
||||
isThreadRoot?: boolean;
|
||||
}): string | undefined {
|
||||
const { threadReplies, messageId, threadRootId } = params;
|
||||
if (threadReplies === "off") {
|
||||
return undefined;
|
||||
}
|
||||
const isThreadRoot = params.isThreadRoot === true;
|
||||
const hasInboundThread = Boolean(threadRootId && threadRootId !== messageId && !isThreadRoot);
|
||||
if (threadReplies === "inbound") {
|
||||
return hasInboundThread ? threadRootId : undefined;
|
||||
}
|
||||
if (threadReplies === "always") {
|
||||
return threadRootId ?? messageId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveMatrixThreadRootId(params: {
|
||||
event: MatrixRawEvent;
|
||||
content: RoomMessageEventContent;
|
||||
}): string | undefined {
|
||||
const relates = params.content["m.relates_to"];
|
||||
if (!relates || typeof relates !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
if ("rel_type" in relates && relates.rel_type === RelationType.Thread) {
|
||||
if ("event_id" in relates && typeof relates.event_id === "string") {
|
||||
return relates.event_id;
|
||||
}
|
||||
if (
|
||||
"m.in_reply_to" in relates &&
|
||||
typeof relates["m.in_reply_to"] === "object" &&
|
||||
relates["m.in_reply_to"] &&
|
||||
"event_id" in relates["m.in_reply_to"] &&
|
||||
typeof relates["m.in_reply_to"].event_id === "string"
|
||||
) {
|
||||
return relates["m.in_reply_to"].event_id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
28
extensions/matrix-js/src/matrix/monitor/types.ts
Normal file
28
extensions/matrix-js/src/matrix/monitor/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { EncryptedFile, MessageEventContent } from "../sdk.js";
|
||||
export type { MatrixRawEvent } from "../sdk.js";
|
||||
|
||||
export const EventType = {
|
||||
RoomMessage: "m.room.message",
|
||||
RoomMessageEncrypted: "m.room.encrypted",
|
||||
RoomMember: "m.room.member",
|
||||
Location: "m.location",
|
||||
} as const;
|
||||
|
||||
export const RelationType = {
|
||||
Replace: "m.replace",
|
||||
Thread: "m.thread",
|
||||
} as const;
|
||||
|
||||
export type RoomMessageEventContent = MessageEventContent & {
|
||||
url?: string;
|
||||
file?: EncryptedFile;
|
||||
info?: {
|
||||
mimetype?: string;
|
||||
size?: number;
|
||||
};
|
||||
"m.relates_to"?: {
|
||||
rel_type?: string;
|
||||
event_id?: string;
|
||||
"m.in_reply_to"?: { event_id?: string };
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isMatrixVerificationEventType,
|
||||
isMatrixVerificationNoticeBody,
|
||||
isMatrixVerificationRequestMsgType,
|
||||
isMatrixVerificationRoomMessage,
|
||||
} from "./verification-utils.js";
|
||||
|
||||
describe("matrix verification message classifiers", () => {
|
||||
it("recognizes verification event types", () => {
|
||||
expect(isMatrixVerificationEventType("m.key.verification.start")).toBe(true);
|
||||
expect(isMatrixVerificationEventType("m.room.message")).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes verification request message type", () => {
|
||||
expect(isMatrixVerificationRequestMsgType("m.key.verification.request")).toBe(true);
|
||||
expect(isMatrixVerificationRequestMsgType("m.text")).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes verification notice bodies", () => {
|
||||
expect(
|
||||
isMatrixVerificationNoticeBody("Matrix verification started with @alice:example.org."),
|
||||
).toBe(true);
|
||||
expect(isMatrixVerificationNoticeBody("hello world")).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies verification room messages", () => {
|
||||
expect(
|
||||
isMatrixVerificationRoomMessage({
|
||||
msgtype: "m.key.verification.request",
|
||||
body: "verify request",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isMatrixVerificationRoomMessage({
|
||||
msgtype: "m.notice",
|
||||
body: "Matrix verification cancelled by @alice:example.org.",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isMatrixVerificationRoomMessage({
|
||||
msgtype: "m.text",
|
||||
body: "normal chat message",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
const VERIFICATION_EVENT_PREFIX = "m.key.verification.";
|
||||
const VERIFICATION_REQUEST_MSGTYPE = "m.key.verification.request";
|
||||
|
||||
const VERIFICATION_NOTICE_PREFIXES = [
|
||||
"Matrix verification request received from ",
|
||||
"Matrix verification is ready with ",
|
||||
"Matrix verification started with ",
|
||||
"Matrix verification completed with ",
|
||||
"Matrix verification cancelled by ",
|
||||
"Matrix verification SAS with ",
|
||||
];
|
||||
|
||||
function trimMaybeString(input: unknown): string {
|
||||
return typeof input === "string" ? input.trim() : "";
|
||||
}
|
||||
|
||||
export function isMatrixVerificationEventType(type: unknown): boolean {
|
||||
return trimMaybeString(type).startsWith(VERIFICATION_EVENT_PREFIX);
|
||||
}
|
||||
|
||||
export function isMatrixVerificationRequestMsgType(msgtype: unknown): boolean {
|
||||
return trimMaybeString(msgtype) === VERIFICATION_REQUEST_MSGTYPE;
|
||||
}
|
||||
|
||||
export function isMatrixVerificationNoticeBody(body: unknown): boolean {
|
||||
const text = trimMaybeString(body);
|
||||
return VERIFICATION_NOTICE_PREFIXES.some((prefix) => text.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function isMatrixVerificationRoomMessage(content: {
|
||||
msgtype?: unknown;
|
||||
body?: unknown;
|
||||
}): boolean {
|
||||
return (
|
||||
isMatrixVerificationRequestMsgType(content.msgtype) ||
|
||||
(trimMaybeString(content.msgtype) === "m.notice" &&
|
||||
isMatrixVerificationNoticeBody(content.body))
|
||||
);
|
||||
}
|
||||
|
||||
export const matrixVerificationConstants = {
|
||||
eventPrefix: VERIFICATION_EVENT_PREFIX,
|
||||
requestMsgtype: VERIFICATION_REQUEST_MSGTYPE,
|
||||
} as const;
|
||||
21
extensions/matrix-js/src/matrix/poll-types.test.ts
Normal file
21
extensions/matrix-js/src/matrix/poll-types.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parsePollStartContent } from "./poll-types.js";
|
||||
|
||||
describe("parsePollStartContent", () => {
|
||||
it("parses legacy m.poll payloads", () => {
|
||||
const summary = parsePollStartContent({
|
||||
"m.poll": {
|
||||
question: { "m.text": "Lunch?" },
|
||||
kind: "m.poll.disclosed",
|
||||
max_selections: 1,
|
||||
answers: [
|
||||
{ id: "answer1", "m.text": "Yes" },
|
||||
{ id: "answer2", "m.text": "No" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary?.question).toBe("Lunch?");
|
||||
expect(summary?.answers).toEqual(["Yes", "No"]);
|
||||
});
|
||||
});
|
||||
167
extensions/matrix-js/src/matrix/poll-types.ts
Normal file
167
extensions/matrix-js/src/matrix/poll-types.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Matrix Poll Types (MSC3381)
|
||||
*
|
||||
* Defines types for Matrix poll events:
|
||||
* - m.poll.start - Creates a new poll
|
||||
* - m.poll.response - Records a vote
|
||||
* - m.poll.end - Closes a poll
|
||||
*/
|
||||
|
||||
import type { PollInput } from "openclaw/plugin-sdk/matrix-js";
|
||||
|
||||
export const M_POLL_START = "m.poll.start" as const;
|
||||
export const M_POLL_RESPONSE = "m.poll.response" as const;
|
||||
export const M_POLL_END = "m.poll.end" as const;
|
||||
|
||||
export const ORG_POLL_START = "org.matrix.msc3381.poll.start" as const;
|
||||
export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response" as const;
|
||||
export const ORG_POLL_END = "org.matrix.msc3381.poll.end" as const;
|
||||
|
||||
export const POLL_EVENT_TYPES = [
|
||||
M_POLL_START,
|
||||
M_POLL_RESPONSE,
|
||||
M_POLL_END,
|
||||
ORG_POLL_START,
|
||||
ORG_POLL_RESPONSE,
|
||||
ORG_POLL_END,
|
||||
];
|
||||
|
||||
export const POLL_START_TYPES = [M_POLL_START, ORG_POLL_START];
|
||||
export const POLL_RESPONSE_TYPES = [M_POLL_RESPONSE, ORG_POLL_RESPONSE];
|
||||
export const POLL_END_TYPES = [M_POLL_END, ORG_POLL_END];
|
||||
|
||||
export type PollKind = "m.poll.disclosed" | "m.poll.undisclosed";
|
||||
|
||||
export type TextContent = {
|
||||
"m.text"?: string;
|
||||
"org.matrix.msc1767.text"?: string;
|
||||
body?: string;
|
||||
};
|
||||
|
||||
export type PollAnswer = {
|
||||
id: string;
|
||||
} & TextContent;
|
||||
|
||||
export type PollStartSubtype = {
|
||||
question: TextContent;
|
||||
kind?: PollKind;
|
||||
max_selections?: number;
|
||||
answers: PollAnswer[];
|
||||
};
|
||||
|
||||
export type LegacyPollStartContent = {
|
||||
"m.poll"?: PollStartSubtype;
|
||||
};
|
||||
|
||||
export type PollStartContent = {
|
||||
[M_POLL_START]?: PollStartSubtype;
|
||||
[ORG_POLL_START]?: PollStartSubtype;
|
||||
"m.poll"?: PollStartSubtype;
|
||||
"m.text"?: string;
|
||||
"org.matrix.msc1767.text"?: string;
|
||||
};
|
||||
|
||||
export type PollSummary = {
|
||||
eventId: string;
|
||||
roomId: string;
|
||||
sender: string;
|
||||
senderName: string;
|
||||
question: string;
|
||||
answers: string[];
|
||||
kind: PollKind;
|
||||
maxSelections: number;
|
||||
};
|
||||
|
||||
export function isPollStartType(eventType: string): boolean {
|
||||
return (POLL_START_TYPES as readonly string[]).includes(eventType);
|
||||
}
|
||||
|
||||
export function getTextContent(text?: TextContent): string {
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
return text["m.text"] ?? text["org.matrix.msc1767.text"] ?? text.body ?? "";
|
||||
}
|
||||
|
||||
export function parsePollStartContent(content: PollStartContent): PollSummary | null {
|
||||
const poll =
|
||||
(content as Record<string, PollStartSubtype | undefined>)[M_POLL_START] ??
|
||||
(content as Record<string, PollStartSubtype | undefined>)[ORG_POLL_START] ??
|
||||
(content as Record<string, PollStartSubtype | undefined>)["m.poll"];
|
||||
if (!poll) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const question = getTextContent(poll.question);
|
||||
if (!question) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const answers = poll.answers
|
||||
.map((answer) => getTextContent(answer))
|
||||
.filter((a) => a.trim().length > 0);
|
||||
|
||||
return {
|
||||
eventId: "",
|
||||
roomId: "",
|
||||
sender: "",
|
||||
senderName: "",
|
||||
question,
|
||||
answers,
|
||||
kind: poll.kind ?? "m.poll.disclosed",
|
||||
maxSelections: poll.max_selections ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatPollAsText(summary: PollSummary): string {
|
||||
const lines = [
|
||||
"[Poll]",
|
||||
summary.question,
|
||||
"",
|
||||
...summary.answers.map((answer, idx) => `${idx + 1}. ${answer}`),
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildTextContent(body: string): TextContent {
|
||||
return {
|
||||
"m.text": body,
|
||||
"org.matrix.msc1767.text": body,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPollFallbackText(question: string, answers: string[]): string {
|
||||
if (answers.length === 0) {
|
||||
return question;
|
||||
}
|
||||
return `${question}\n${answers.map((answer, idx) => `${idx + 1}. ${answer}`).join("\n")}`;
|
||||
}
|
||||
|
||||
export function buildPollStartContent(poll: PollInput): PollStartContent {
|
||||
const question = poll.question.trim();
|
||||
const answers = poll.options
|
||||
.map((option) => option.trim())
|
||||
.filter((option) => option.length > 0)
|
||||
.map((option, idx) => ({
|
||||
id: `answer${idx + 1}`,
|
||||
...buildTextContent(option),
|
||||
}));
|
||||
|
||||
const isMultiple = (poll.maxSelections ?? 1) > 1;
|
||||
const maxSelections = isMultiple ? Math.max(1, answers.length) : 1;
|
||||
const fallbackText = buildPollFallbackText(
|
||||
question,
|
||||
answers.map((answer) => getTextContent(answer)),
|
||||
);
|
||||
|
||||
return {
|
||||
[M_POLL_START]: {
|
||||
question: buildTextContent(question),
|
||||
kind: isMultiple ? "m.poll.undisclosed" : "m.poll.disclosed",
|
||||
max_selections: maxSelections,
|
||||
answers,
|
||||
},
|
||||
"m.text": fallbackText,
|
||||
"org.matrix.msc1767.text": fallbackText,
|
||||
};
|
||||
}
|
||||
53
extensions/matrix-js/src/matrix/probe.test.ts
Normal file
53
extensions/matrix-js/src/matrix/probe.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
70
extensions/matrix-js/src/matrix/probe.ts
Normal file
70
extensions/matrix-js/src/matrix/probe.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk/matrix-js";
|
||||
import { createMatrixClient, isBunRuntime } from "./client.js";
|
||||
|
||||
export type MatrixProbe = BaseProbeResult & {
|
||||
status?: number | null;
|
||||
elapsedMs: number;
|
||||
userId?: string | null;
|
||||
};
|
||||
|
||||
export async function probeMatrix(params: {
|
||||
homeserver: string;
|
||||
accessToken: string;
|
||||
userId?: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<MatrixProbe> {
|
||||
const started = Date.now();
|
||||
const result: MatrixProbe = {
|
||||
ok: false,
|
||||
status: null,
|
||||
error: null,
|
||||
elapsedMs: 0,
|
||||
};
|
||||
if (isBunRuntime()) {
|
||||
return {
|
||||
...result,
|
||||
error: "Matrix probe requires Node (bun runtime not supported)",
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
if (!params.homeserver?.trim()) {
|
||||
return {
|
||||
...result,
|
||||
error: "missing homeserver",
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
if (!params.accessToken?.trim()) {
|
||||
return {
|
||||
...result,
|
||||
error: "missing access token",
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const inputUserId = params.userId?.trim() || undefined;
|
||||
const client = await createMatrixClient({
|
||||
homeserver: params.homeserver,
|
||||
userId: inputUserId,
|
||||
accessToken: params.accessToken,
|
||||
localTimeoutMs: params.timeoutMs,
|
||||
});
|
||||
// The client wrapper resolves user ID via whoami when needed.
|
||||
const userId = await client.getUserId();
|
||||
result.ok = true;
|
||||
result.userId = userId ?? null;
|
||||
|
||||
result.elapsedMs = Date.now() - started;
|
||||
return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
...result,
|
||||
status:
|
||||
typeof err === "object" && err && "statusCode" in err
|
||||
? Number((err as { statusCode?: number }).statusCode)
|
||||
: result.status,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
}
|
||||
123
extensions/matrix-js/src/matrix/profile.test.ts
Normal file
123
extensions/matrix-js/src/matrix/profile.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isSupportedMatrixAvatarSource,
|
||||
syncMatrixOwnProfile,
|
||||
type MatrixProfileSyncResult,
|
||||
} from "./profile.js";
|
||||
|
||||
function createClientStub() {
|
||||
return {
|
||||
getUserProfile: vi.fn(async () => ({})),
|
||||
setDisplayName: vi.fn(async () => {}),
|
||||
setAvatarUrl: vi.fn(async () => {}),
|
||||
uploadContent: vi.fn(async () => "mxc://example/avatar"),
|
||||
};
|
||||
}
|
||||
|
||||
function expectNoUpdates(result: MatrixProfileSyncResult) {
|
||||
expect(result.displayNameUpdated).toBe(false);
|
||||
expect(result.avatarUpdated).toBe(false);
|
||||
}
|
||||
|
||||
describe("matrix profile sync", () => {
|
||||
it("skips when no desired profile values are provided", async () => {
|
||||
const client = createClientStub();
|
||||
const result = await syncMatrixOwnProfile({
|
||||
client,
|
||||
userId: "@bot:example.org",
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expectNoUpdates(result);
|
||||
expect(client.setDisplayName).not.toHaveBeenCalled();
|
||||
expect(client.setAvatarUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates display name when desired name differs", async () => {
|
||||
const client = createClientStub();
|
||||
client.getUserProfile.mockResolvedValue({
|
||||
displayname: "Old Name",
|
||||
avatar_url: "mxc://example/existing",
|
||||
});
|
||||
|
||||
const result = await syncMatrixOwnProfile({
|
||||
client,
|
||||
userId: "@bot:example.org",
|
||||
displayName: "New Name",
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(result.displayNameUpdated).toBe(true);
|
||||
expect(result.avatarUpdated).toBe(false);
|
||||
expect(client.setDisplayName).toHaveBeenCalledWith("New Name");
|
||||
});
|
||||
|
||||
it("does not update when name and avatar already match", async () => {
|
||||
const client = createClientStub();
|
||||
client.getUserProfile.mockResolvedValue({
|
||||
displayname: "Bot",
|
||||
avatar_url: "mxc://example/avatar",
|
||||
});
|
||||
|
||||
const result = await syncMatrixOwnProfile({
|
||||
client,
|
||||
userId: "@bot:example.org",
|
||||
displayName: "Bot",
|
||||
avatarUrl: "mxc://example/avatar",
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expectNoUpdates(result);
|
||||
expect(client.setDisplayName).not.toHaveBeenCalled();
|
||||
expect(client.setAvatarUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("converts http avatar URL by uploading and then updates profile avatar", async () => {
|
||||
const client = createClientStub();
|
||||
client.getUserProfile.mockResolvedValue({
|
||||
displayname: "Bot",
|
||||
avatar_url: "mxc://example/old",
|
||||
});
|
||||
client.uploadContent.mockResolvedValue("mxc://example/new-avatar");
|
||||
const loadAvatarFromUrl = vi.fn(async () => ({
|
||||
buffer: Buffer.from("avatar-bytes"),
|
||||
contentType: "image/png",
|
||||
fileName: "avatar.png",
|
||||
}));
|
||||
|
||||
const result = await syncMatrixOwnProfile({
|
||||
client,
|
||||
userId: "@bot:example.org",
|
||||
avatarUrl: "https://cdn.example.org/avatar.png",
|
||||
loadAvatarFromUrl,
|
||||
});
|
||||
|
||||
expect(result.convertedAvatarFromHttp).toBe(true);
|
||||
expect(result.resolvedAvatarUrl).toBe("mxc://example/new-avatar");
|
||||
expect(result.avatarUpdated).toBe(true);
|
||||
expect(loadAvatarFromUrl).toHaveBeenCalledWith(
|
||||
"https://cdn.example.org/avatar.png",
|
||||
10 * 1024 * 1024,
|
||||
);
|
||||
expect(client.setAvatarUrl).toHaveBeenCalledWith("mxc://example/new-avatar");
|
||||
});
|
||||
|
||||
it("rejects unsupported avatar URL schemes", async () => {
|
||||
const client = createClientStub();
|
||||
|
||||
await expect(
|
||||
syncMatrixOwnProfile({
|
||||
client,
|
||||
userId: "@bot:example.org",
|
||||
avatarUrl: "file:///tmp/avatar.png",
|
||||
}),
|
||||
).rejects.toThrow("Matrix avatar URL must be an mxc:// URI or an http(s) URL.");
|
||||
});
|
||||
|
||||
it("recognizes supported avatar sources", () => {
|
||||
expect(isSupportedMatrixAvatarSource("mxc://example/avatar")).toBe(true);
|
||||
expect(isSupportedMatrixAvatarSource("https://example.org/avatar.png")).toBe(true);
|
||||
expect(isSupportedMatrixAvatarSource("http://example.org/avatar.png")).toBe(true);
|
||||
expect(isSupportedMatrixAvatarSource("ftp://example.org/avatar.png")).toBe(false);
|
||||
});
|
||||
});
|
||||
143
extensions/matrix-js/src/matrix/profile.ts
Normal file
143
extensions/matrix-js/src/matrix/profile.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
|
||||
export const MATRIX_PROFILE_AVATAR_MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
type MatrixProfileClient = Pick<
|
||||
MatrixClient,
|
||||
"getUserProfile" | "setDisplayName" | "setAvatarUrl" | "uploadContent"
|
||||
>;
|
||||
|
||||
type MatrixProfileLoadResult = {
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
export type MatrixProfileSyncResult = {
|
||||
skipped: boolean;
|
||||
displayNameUpdated: boolean;
|
||||
avatarUpdated: boolean;
|
||||
resolvedAvatarUrl: string | null;
|
||||
convertedAvatarFromHttp: boolean;
|
||||
};
|
||||
|
||||
function normalizeOptionalText(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function isMatrixMxcUri(value: string): boolean {
|
||||
return value.trim().toLowerCase().startsWith("mxc://");
|
||||
}
|
||||
|
||||
export function isMatrixHttpAvatarUri(value: string): boolean {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized.startsWith("https://") || normalized.startsWith("http://");
|
||||
}
|
||||
|
||||
export function isSupportedMatrixAvatarSource(value: string): boolean {
|
||||
return isMatrixMxcUri(value) || isMatrixHttpAvatarUri(value);
|
||||
}
|
||||
|
||||
async function resolveAvatarUrl(params: {
|
||||
client: MatrixProfileClient;
|
||||
avatarUrl: string | null;
|
||||
avatarMaxBytes: number;
|
||||
loadAvatarFromUrl?: (url: string, maxBytes: number) => Promise<MatrixProfileLoadResult>;
|
||||
}): Promise<{ resolvedAvatarUrl: string | null; convertedAvatarFromHttp: boolean }> {
|
||||
const avatarUrl = normalizeOptionalText(params.avatarUrl);
|
||||
if (!avatarUrl) {
|
||||
return {
|
||||
resolvedAvatarUrl: null,
|
||||
convertedAvatarFromHttp: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (isMatrixMxcUri(avatarUrl)) {
|
||||
return {
|
||||
resolvedAvatarUrl: avatarUrl,
|
||||
convertedAvatarFromHttp: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isMatrixHttpAvatarUri(avatarUrl)) {
|
||||
throw new Error("Matrix avatar URL must be an mxc:// URI or an http(s) URL.");
|
||||
}
|
||||
|
||||
if (!params.loadAvatarFromUrl) {
|
||||
throw new Error("Matrix avatar URL conversion requires a media loader.");
|
||||
}
|
||||
|
||||
const media = await params.loadAvatarFromUrl(avatarUrl, params.avatarMaxBytes);
|
||||
const uploadedMxc = await params.client.uploadContent(
|
||||
media.buffer,
|
||||
media.contentType,
|
||||
media.fileName || "avatar",
|
||||
);
|
||||
|
||||
return {
|
||||
resolvedAvatarUrl: uploadedMxc,
|
||||
convertedAvatarFromHttp: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncMatrixOwnProfile(params: {
|
||||
client: MatrixProfileClient;
|
||||
userId: string;
|
||||
displayName?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
avatarMaxBytes?: number;
|
||||
loadAvatarFromUrl?: (url: string, maxBytes: number) => Promise<MatrixProfileLoadResult>;
|
||||
}): Promise<MatrixProfileSyncResult> {
|
||||
const desiredDisplayName = normalizeOptionalText(params.displayName);
|
||||
const avatar = await resolveAvatarUrl({
|
||||
client: params.client,
|
||||
avatarUrl: params.avatarUrl ?? null,
|
||||
avatarMaxBytes: params.avatarMaxBytes ?? MATRIX_PROFILE_AVATAR_MAX_BYTES,
|
||||
loadAvatarFromUrl: params.loadAvatarFromUrl,
|
||||
});
|
||||
const desiredAvatarUrl = avatar.resolvedAvatarUrl;
|
||||
|
||||
if (!desiredDisplayName && !desiredAvatarUrl) {
|
||||
return {
|
||||
skipped: true,
|
||||
displayNameUpdated: false,
|
||||
avatarUpdated: false,
|
||||
resolvedAvatarUrl: null,
|
||||
convertedAvatarFromHttp: avatar.convertedAvatarFromHttp,
|
||||
};
|
||||
}
|
||||
|
||||
let currentDisplayName: string | undefined;
|
||||
let currentAvatarUrl: string | undefined;
|
||||
try {
|
||||
const currentProfile = await params.client.getUserProfile(params.userId);
|
||||
currentDisplayName = normalizeOptionalText(currentProfile.displayname) ?? undefined;
|
||||
currentAvatarUrl = normalizeOptionalText(currentProfile.avatar_url) ?? undefined;
|
||||
} catch {
|
||||
// If profile fetch fails, attempt writes directly.
|
||||
}
|
||||
|
||||
let displayNameUpdated = false;
|
||||
let avatarUpdated = false;
|
||||
|
||||
if (desiredDisplayName && currentDisplayName !== desiredDisplayName) {
|
||||
await params.client.setDisplayName(desiredDisplayName);
|
||||
displayNameUpdated = true;
|
||||
}
|
||||
if (desiredAvatarUrl && currentAvatarUrl !== desiredAvatarUrl) {
|
||||
await params.client.setAvatarUrl(desiredAvatarUrl);
|
||||
avatarUpdated = true;
|
||||
}
|
||||
|
||||
return {
|
||||
skipped: false,
|
||||
displayNameUpdated,
|
||||
avatarUpdated,
|
||||
resolvedAvatarUrl: desiredAvatarUrl,
|
||||
convertedAvatarFromHttp: avatar.convertedAvatarFromHttp,
|
||||
};
|
||||
}
|
||||
1279
extensions/matrix-js/src/matrix/sdk.test.ts
Normal file
1279
extensions/matrix-js/src/matrix/sdk.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1113
extensions/matrix-js/src/matrix/sdk.ts
Normal file
1113
extensions/matrix-js/src/matrix/sdk.ts
Normal file
File diff suppressed because it is too large
Load Diff
355
extensions/matrix-js/src/matrix/sdk/crypto-bootstrap.test.ts
Normal file
355
extensions/matrix-js/src/matrix/sdk/crypto-bootstrap.test.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
import { VerificationPhase } from "matrix-js-sdk/lib/crypto-api/verification.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MatrixCryptoBootstrapper, type MatrixCryptoBootstrapperDeps } from "./crypto-bootstrap.js";
|
||||
import type { MatrixCryptoBootstrapApi, MatrixRawEvent } from "./types.js";
|
||||
|
||||
function createBootstrapperDeps() {
|
||||
return {
|
||||
getUserId: vi.fn(async () => "@bot:example.org"),
|
||||
getPassword: vi.fn(() => "super-secret-password"),
|
||||
getDeviceId: vi.fn(() => "DEVICE123"),
|
||||
verificationManager: {
|
||||
trackVerificationRequest: vi.fn(),
|
||||
},
|
||||
recoveryKeyStore: {
|
||||
bootstrapSecretStorageWithRecoveryKey: vi.fn(async () => {}),
|
||||
},
|
||||
decryptBridge: {
|
||||
bindCryptoRetrySignals: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createCryptoApi(overrides?: Partial<MatrixCryptoBootstrapApi>): MatrixCryptoBootstrapApi {
|
||||
return {
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(async () => {}),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MatrixCryptoBootstrapper", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("bootstraps cross-signing/secret-storage and binds decrypt retry signals", async () => {
|
||||
const deps = createBootstrapperDeps();
|
||||
const crypto = createCryptoApi({
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
})),
|
||||
});
|
||||
const bootstrapper = new MatrixCryptoBootstrapper(
|
||||
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
|
||||
);
|
||||
|
||||
await bootstrapper.bootstrap(crypto);
|
||||
|
||||
expect(crypto.bootstrapCrossSigning).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authUploadDeviceSigningKeys: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith(
|
||||
crypto,
|
||||
);
|
||||
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledTimes(2);
|
||||
expect(deps.decryptBridge.bindCryptoRetrySignals).toHaveBeenCalledWith(crypto);
|
||||
});
|
||||
|
||||
it("forces new cross-signing keys only when readiness check still fails", async () => {
|
||||
const deps = createBootstrapperDeps();
|
||||
const bootstrapCrossSigning = vi.fn(async () => {});
|
||||
const crypto = createCryptoApi({
|
||||
bootstrapCrossSigning,
|
||||
isCrossSigningReady: vi
|
||||
.fn<() => Promise<boolean>>()
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true),
|
||||
userHasCrossSigningKeys: vi
|
||||
.fn<() => Promise<boolean>>()
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true),
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
})),
|
||||
});
|
||||
const bootstrapper = new MatrixCryptoBootstrapper(
|
||||
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
|
||||
);
|
||||
|
||||
await bootstrapper.bootstrap(crypto);
|
||||
|
||||
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2);
|
||||
expect(bootstrapCrossSigning).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
authUploadDeviceSigningKeys: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(bootstrapCrossSigning).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
setupNewCrossSigning: true,
|
||||
authUploadDeviceSigningKeys: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails in strict mode when cross-signing keys are still unpublished", async () => {
|
||||
const deps = createBootstrapperDeps();
|
||||
const crypto = createCryptoApi({
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
isCrossSigningReady: vi.fn(async () => false),
|
||||
userHasCrossSigningKeys: vi.fn(async () => false),
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
})),
|
||||
});
|
||||
const bootstrapper = new MatrixCryptoBootstrapper(
|
||||
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
|
||||
);
|
||||
|
||||
await expect(bootstrapper.bootstrap(crypto, { strict: true })).rejects.toThrow(
|
||||
"Cross-signing bootstrap finished but server keys are still not published",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses password UIA fallback when null and dummy auth fail", async () => {
|
||||
const deps = createBootstrapperDeps();
|
||||
const bootstrapCrossSigning = vi.fn(async () => {});
|
||||
const crypto = createCryptoApi({
|
||||
bootstrapCrossSigning,
|
||||
isCrossSigningReady: vi.fn(async () => true),
|
||||
userHasCrossSigningKeys: vi.fn(async () => true),
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
})),
|
||||
});
|
||||
const bootstrapper = new MatrixCryptoBootstrapper(
|
||||
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
|
||||
);
|
||||
|
||||
await bootstrapper.bootstrap(crypto);
|
||||
|
||||
const bootstrapCrossSigningCalls = bootstrapCrossSigning.mock.calls as Array<
|
||||
[
|
||||
{
|
||||
authUploadDeviceSigningKeys?: <T>(
|
||||
makeRequest: (authData: Record<string, unknown> | null) => Promise<T>,
|
||||
) => Promise<T>;
|
||||
}?,
|
||||
]
|
||||
>;
|
||||
const authUploadDeviceSigningKeys =
|
||||
bootstrapCrossSigningCalls[0]?.[0]?.authUploadDeviceSigningKeys;
|
||||
expect(authUploadDeviceSigningKeys).toBeTypeOf("function");
|
||||
|
||||
const seenAuthStages: Array<Record<string, unknown> | null> = [];
|
||||
const result = await authUploadDeviceSigningKeys?.(async (authData) => {
|
||||
seenAuthStages.push(authData);
|
||||
if (authData === null) {
|
||||
throw new Error("need auth");
|
||||
}
|
||||
if (authData.type === "m.login.dummy") {
|
||||
throw new Error("dummy rejected");
|
||||
}
|
||||
if (authData.type === "m.login.password") {
|
||||
return "ok";
|
||||
}
|
||||
throw new Error("unexpected auth stage");
|
||||
});
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(seenAuthStages).toEqual([
|
||||
null,
|
||||
{ type: "m.login.dummy" },
|
||||
{
|
||||
type: "m.login.password",
|
||||
identifier: { type: "m.id.user", user: "@bot:example.org" },
|
||||
password: "super-secret-password",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("resets cross-signing when first bootstrap attempt throws", async () => {
|
||||
const deps = createBootstrapperDeps();
|
||||
const bootstrapCrossSigning = vi
|
||||
.fn<() => Promise<void>>()
|
||||
.mockRejectedValueOnce(new Error("first attempt failed"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const crypto = createCryptoApi({
|
||||
bootstrapCrossSigning,
|
||||
isCrossSigningReady: vi.fn(async () => true),
|
||||
userHasCrossSigningKeys: vi.fn(async () => true),
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
})),
|
||||
});
|
||||
const bootstrapper = new MatrixCryptoBootstrapper(
|
||||
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
|
||||
);
|
||||
|
||||
await bootstrapper.bootstrap(crypto);
|
||||
|
||||
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2);
|
||||
expect(bootstrapCrossSigning).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
setupNewCrossSigning: true,
|
||||
authUploadDeviceSigningKeys: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("marks own device verified and cross-signs it when needed", async () => {
|
||||
const deps = createBootstrapperDeps();
|
||||
const setDeviceVerified = vi.fn(async () => {});
|
||||
const crossSignDevice = vi.fn(async () => {});
|
||||
const crypto = createCryptoApi({
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => false,
|
||||
localVerified: false,
|
||||
crossSigningVerified: false,
|
||||
signedByOwner: false,
|
||||
})),
|
||||
setDeviceVerified,
|
||||
crossSignDevice,
|
||||
isCrossSigningReady: vi.fn(async () => true),
|
||||
});
|
||||
const bootstrapper = new MatrixCryptoBootstrapper(
|
||||
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
|
||||
);
|
||||
|
||||
await bootstrapper.bootstrap(crypto);
|
||||
|
||||
expect(setDeviceVerified).toHaveBeenCalledWith("@bot:example.org", "DEVICE123", true);
|
||||
expect(crossSignDevice).toHaveBeenCalledWith("DEVICE123");
|
||||
});
|
||||
|
||||
it("auto-accepts incoming verification requests from other users", async () => {
|
||||
const deps = createBootstrapperDeps();
|
||||
const listeners = new Map<string, (...args: unknown[]) => void>();
|
||||
const crypto = createCryptoApi({
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
})),
|
||||
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
|
||||
listeners.set(eventName, listener);
|
||||
}),
|
||||
});
|
||||
const bootstrapper = new MatrixCryptoBootstrapper(
|
||||
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
|
||||
);
|
||||
|
||||
await bootstrapper.bootstrap(crypto);
|
||||
|
||||
const verificationRequest = {
|
||||
otherUserId: "@alice:example.org",
|
||||
isSelfVerification: false,
|
||||
initiatedByMe: false,
|
||||
accept: vi.fn(async () => {}),
|
||||
};
|
||||
const listener = Array.from(listeners.entries()).find(([eventName]) =>
|
||||
eventName.toLowerCase().includes("verificationrequest"),
|
||||
)?.[1];
|
||||
expect(listener).toBeTypeOf("function");
|
||||
await listener?.(verificationRequest);
|
||||
|
||||
expect(deps.verificationManager.trackVerificationRequest).toHaveBeenCalledWith(
|
||||
verificationRequest,
|
||||
);
|
||||
expect(verificationRequest.accept).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still auto-accepts verification when tracking summary throws", async () => {
|
||||
const deps = createBootstrapperDeps();
|
||||
deps.verificationManager.trackVerificationRequest = vi.fn(() => {
|
||||
throw new Error("summary failure");
|
||||
});
|
||||
const listeners = new Map<string, (...args: unknown[]) => void>();
|
||||
const crypto = createCryptoApi({
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
})),
|
||||
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
|
||||
listeners.set(eventName, listener);
|
||||
}),
|
||||
});
|
||||
const bootstrapper = new MatrixCryptoBootstrapper(
|
||||
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
|
||||
);
|
||||
|
||||
await bootstrapper.bootstrap(crypto);
|
||||
|
||||
const verificationRequest = {
|
||||
otherUserId: "@alice:example.org",
|
||||
isSelfVerification: false,
|
||||
initiatedByMe: false,
|
||||
accept: vi.fn(async () => {}),
|
||||
};
|
||||
const listener = Array.from(listeners.entries()).find(([eventName]) =>
|
||||
eventName.toLowerCase().includes("verificationrequest"),
|
||||
)?.[1];
|
||||
expect(listener).toBeTypeOf("function");
|
||||
await listener?.(verificationRequest);
|
||||
|
||||
expect(verificationRequest.accept).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips auto-accept for requests that are no longer requested", async () => {
|
||||
const deps = createBootstrapperDeps();
|
||||
const listeners = new Map<string, (...args: unknown[]) => void>();
|
||||
const crypto = createCryptoApi({
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
})),
|
||||
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
|
||||
listeners.set(eventName, listener);
|
||||
}),
|
||||
});
|
||||
const bootstrapper = new MatrixCryptoBootstrapper(
|
||||
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
|
||||
);
|
||||
|
||||
await bootstrapper.bootstrap(crypto);
|
||||
|
||||
const verificationRequest = {
|
||||
otherUserId: "@alice:example.org",
|
||||
isSelfVerification: false,
|
||||
initiatedByMe: false,
|
||||
phase: VerificationPhase.Cancelled,
|
||||
accepting: false,
|
||||
declining: false,
|
||||
accept: vi.fn(async () => {}),
|
||||
};
|
||||
const listener = Array.from(listeners.entries()).find(([eventName]) =>
|
||||
eventName.toLowerCase().includes("verificationrequest"),
|
||||
)?.[1];
|
||||
expect(listener).toBeTypeOf("function");
|
||||
await listener?.(verificationRequest);
|
||||
|
||||
expect(verificationRequest.accept).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers verification listeners only once across repeated bootstrap calls", async () => {
|
||||
const deps = createBootstrapperDeps();
|
||||
const crypto = createCryptoApi({
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
})),
|
||||
});
|
||||
const bootstrapper = new MatrixCryptoBootstrapper(
|
||||
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
|
||||
);
|
||||
|
||||
await bootstrapper.bootstrap(crypto);
|
||||
await bootstrapper.bootstrap(crypto);
|
||||
|
||||
expect(crypto.on).toHaveBeenCalledTimes(1);
|
||||
expect(deps.decryptBridge.bindCryptoRetrySignals).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
334
extensions/matrix-js/src/matrix/sdk/crypto-bootstrap.ts
Normal file
334
extensions/matrix-js/src/matrix/sdk/crypto-bootstrap.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js";
|
||||
import { VerificationPhase } from "matrix-js-sdk/lib/crypto-api/verification.js";
|
||||
import type { MatrixDecryptBridge } from "./decrypt-bridge.js";
|
||||
import { LogService } from "./logger.js";
|
||||
import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
|
||||
import type {
|
||||
MatrixAuthDict,
|
||||
MatrixCryptoBootstrapApi,
|
||||
MatrixRawEvent,
|
||||
MatrixUiAuthCallback,
|
||||
} from "./types.js";
|
||||
import type {
|
||||
MatrixVerificationManager,
|
||||
MatrixVerificationRequestLike,
|
||||
} from "./verification-manager.js";
|
||||
|
||||
export type MatrixCryptoBootstrapperDeps<TRawEvent extends MatrixRawEvent> = {
|
||||
getUserId: () => Promise<string>;
|
||||
getPassword?: () => string | undefined;
|
||||
getDeviceId: () => string | null | undefined;
|
||||
verificationManager: MatrixVerificationManager;
|
||||
recoveryKeyStore: MatrixRecoveryKeyStore;
|
||||
decryptBridge: Pick<MatrixDecryptBridge<TRawEvent>, "bindCryptoRetrySignals">;
|
||||
};
|
||||
|
||||
export type MatrixCryptoBootstrapOptions = {
|
||||
forceResetCrossSigning?: boolean;
|
||||
strict?: boolean;
|
||||
};
|
||||
|
||||
export type MatrixCryptoBootstrapResult = {
|
||||
crossSigningReady: boolean;
|
||||
crossSigningPublished: boolean;
|
||||
ownDeviceVerified: boolean | null;
|
||||
};
|
||||
|
||||
export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
||||
private verificationHandlerRegistered = false;
|
||||
|
||||
constructor(private readonly deps: MatrixCryptoBootstrapperDeps<TRawEvent>) {}
|
||||
|
||||
async bootstrap(
|
||||
crypto: MatrixCryptoBootstrapApi,
|
||||
options: MatrixCryptoBootstrapOptions = {},
|
||||
): Promise<MatrixCryptoBootstrapResult> {
|
||||
const strict = options.strict === true;
|
||||
// Register verification listeners before expensive bootstrap work so incoming requests
|
||||
// are not missed during startup.
|
||||
this.registerVerificationRequestHandler(crypto);
|
||||
await this.bootstrapSecretStorage(crypto, strict);
|
||||
const crossSigning = await this.bootstrapCrossSigning(crypto, {
|
||||
forceResetCrossSigning: options.forceResetCrossSigning === true,
|
||||
strict,
|
||||
});
|
||||
await this.bootstrapSecretStorage(crypto, strict);
|
||||
const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, strict);
|
||||
return {
|
||||
crossSigningReady: crossSigning.ready,
|
||||
crossSigningPublished: crossSigning.published,
|
||||
ownDeviceVerified,
|
||||
};
|
||||
}
|
||||
|
||||
private createSigningKeysUiAuthCallback(params: {
|
||||
userId: string;
|
||||
password?: string;
|
||||
}): MatrixUiAuthCallback {
|
||||
return async <T>(makeRequest: (authData: MatrixAuthDict | null) => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return await makeRequest(null);
|
||||
} catch {
|
||||
// Some homeservers require an explicit dummy UIA stage even when no user interaction is needed.
|
||||
try {
|
||||
return await makeRequest({ type: "m.login.dummy" });
|
||||
} catch {
|
||||
if (!params.password?.trim()) {
|
||||
throw new Error(
|
||||
"Matrix cross-signing key upload requires UIA; provide matrix-js.password for m.login.password fallback",
|
||||
);
|
||||
}
|
||||
return await makeRequest({
|
||||
type: "m.login.password",
|
||||
identifier: { type: "m.id.user", user: params.userId },
|
||||
password: params.password,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async bootstrapCrossSigning(
|
||||
crypto: MatrixCryptoBootstrapApi,
|
||||
options: { forceResetCrossSigning: boolean; strict: boolean },
|
||||
): Promise<{ ready: boolean; published: boolean }> {
|
||||
const userId = await this.deps.getUserId();
|
||||
const authUploadDeviceSigningKeys = this.createSigningKeysUiAuthCallback({
|
||||
userId,
|
||||
password: this.deps.getPassword?.(),
|
||||
});
|
||||
const hasPublishedCrossSigningKeys = async (): Promise<boolean> => {
|
||||
if (typeof crypto.userHasCrossSigningKeys !== "function") {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return await crypto.userHasCrossSigningKeys(userId, true);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const isCrossSigningReady = async (): Promise<boolean> => {
|
||||
if (typeof crypto.isCrossSigningReady !== "function") {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return await crypto.isCrossSigningReady();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const finalize = async (): Promise<{ ready: boolean; published: boolean }> => {
|
||||
const ready = await isCrossSigningReady();
|
||||
const published = await hasPublishedCrossSigningKeys();
|
||||
if (ready && published) {
|
||||
LogService.info("MatrixClientLite", "Cross-signing bootstrap complete");
|
||||
return { ready, published };
|
||||
}
|
||||
const message = "Cross-signing bootstrap finished but server keys are still not published";
|
||||
LogService.warn("MatrixClientLite", message);
|
||||
if (options.strict) {
|
||||
throw new Error(message);
|
||||
}
|
||||
return { ready, published };
|
||||
};
|
||||
|
||||
if (options.forceResetCrossSigning) {
|
||||
try {
|
||||
await crypto.bootstrapCrossSigning({
|
||||
setupNewCrossSigning: true,
|
||||
authUploadDeviceSigningKeys,
|
||||
});
|
||||
} catch (err) {
|
||||
LogService.warn("MatrixClientLite", "Forced cross-signing reset failed:", err);
|
||||
if (options.strict) {
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
return { ready: false, published: false };
|
||||
}
|
||||
return await finalize();
|
||||
}
|
||||
|
||||
// First pass: preserve existing cross-signing identity and ensure public keys are uploaded.
|
||||
try {
|
||||
await crypto.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys,
|
||||
});
|
||||
} catch (err) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
"Initial cross-signing bootstrap failed, trying reset:",
|
||||
err,
|
||||
);
|
||||
try {
|
||||
await crypto.bootstrapCrossSigning({
|
||||
setupNewCrossSigning: true,
|
||||
authUploadDeviceSigningKeys,
|
||||
});
|
||||
} catch (resetErr) {
|
||||
LogService.warn("MatrixClientLite", "Failed to bootstrap cross-signing:", resetErr);
|
||||
if (options.strict) {
|
||||
throw resetErr instanceof Error ? resetErr : new Error(String(resetErr));
|
||||
}
|
||||
return { ready: false, published: false };
|
||||
}
|
||||
}
|
||||
|
||||
const firstPassReady = await isCrossSigningReady();
|
||||
const firstPassPublished = await hasPublishedCrossSigningKeys();
|
||||
if (firstPassReady && firstPassPublished) {
|
||||
LogService.info("MatrixClientLite", "Cross-signing bootstrap complete");
|
||||
return { ready: true, published: true };
|
||||
}
|
||||
|
||||
// Fallback: recover from broken local/server state by creating a fresh identity.
|
||||
try {
|
||||
await crypto.bootstrapCrossSigning({
|
||||
setupNewCrossSigning: true,
|
||||
authUploadDeviceSigningKeys,
|
||||
});
|
||||
} catch (err) {
|
||||
LogService.warn("MatrixClientLite", "Fallback cross-signing bootstrap failed:", err);
|
||||
if (options.strict) {
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
return { ready: false, published: false };
|
||||
}
|
||||
|
||||
return await finalize();
|
||||
}
|
||||
|
||||
private async bootstrapSecretStorage(
|
||||
crypto: MatrixCryptoBootstrapApi,
|
||||
strict = false,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto);
|
||||
LogService.info("MatrixClientLite", "Secret storage bootstrap complete");
|
||||
} catch (err) {
|
||||
LogService.warn("MatrixClientLite", "Failed to bootstrap secret storage:", err);
|
||||
if (strict) {
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private registerVerificationRequestHandler(crypto: MatrixCryptoBootstrapApi): void {
|
||||
if (this.verificationHandlerRegistered) {
|
||||
return;
|
||||
}
|
||||
this.verificationHandlerRegistered = true;
|
||||
|
||||
// Auto-accept incoming verification requests from other users/devices.
|
||||
crypto.on(CryptoEvent.VerificationRequestReceived, async (request) => {
|
||||
const verificationRequest = request as MatrixVerificationRequestLike;
|
||||
try {
|
||||
this.deps.verificationManager.trackVerificationRequest(verificationRequest);
|
||||
} catch (err) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
`Failed to track verification request from ${verificationRequest.otherUserId}:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
const otherUserId = verificationRequest.otherUserId;
|
||||
const isSelfVerification = verificationRequest.isSelfVerification;
|
||||
const initiatedByMe = verificationRequest.initiatedByMe;
|
||||
const phase =
|
||||
typeof verificationRequest.phase === "number"
|
||||
? verificationRequest.phase
|
||||
: VerificationPhase.Requested;
|
||||
const accepting = verificationRequest.accepting === true;
|
||||
const declining = verificationRequest.declining === true;
|
||||
|
||||
if (isSelfVerification || initiatedByMe) {
|
||||
LogService.debug(
|
||||
"MatrixClientLite",
|
||||
`Ignoring ${isSelfVerification ? "self" : "initiated"} verification request from ${otherUserId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (phase !== VerificationPhase.Requested || accepting || declining) {
|
||||
LogService.debug(
|
||||
"MatrixClientLite",
|
||||
`Skipping auto-accept for ${otherUserId} in phase=${phase} accepting=${accepting} declining=${declining}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
LogService.info(
|
||||
"MatrixClientLite",
|
||||
`Auto-accepting verification request from ${otherUserId}`,
|
||||
);
|
||||
await verificationRequest.accept();
|
||||
LogService.info(
|
||||
"MatrixClientLite",
|
||||
`Verification request from ${otherUserId} accepted, waiting for SAS...`,
|
||||
);
|
||||
} catch (err) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
`Failed to auto-accept verification from ${otherUserId}:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.deps.decryptBridge.bindCryptoRetrySignals(crypto);
|
||||
LogService.info("MatrixClientLite", "Verification request handler registered");
|
||||
}
|
||||
|
||||
private async ensureOwnDeviceTrust(
|
||||
crypto: MatrixCryptoBootstrapApi,
|
||||
strict = false,
|
||||
): Promise<boolean | null> {
|
||||
const deviceId = this.deps.getDeviceId()?.trim();
|
||||
if (!deviceId) {
|
||||
return null;
|
||||
}
|
||||
const userId = await this.deps.getUserId();
|
||||
|
||||
const deviceStatus =
|
||||
typeof crypto.getDeviceVerificationStatus === "function"
|
||||
? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null)
|
||||
: null;
|
||||
const alreadyVerified =
|
||||
deviceStatus?.isVerified?.() === true ||
|
||||
deviceStatus?.localVerified === true ||
|
||||
deviceStatus?.crossSigningVerified === true ||
|
||||
deviceStatus?.signedByOwner === true;
|
||||
|
||||
if (alreadyVerified) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof crypto.setDeviceVerified === "function") {
|
||||
await crypto.setDeviceVerified(userId, deviceId, true);
|
||||
}
|
||||
|
||||
if (typeof crypto.crossSignDevice === "function") {
|
||||
const crossSigningReady =
|
||||
typeof crypto.isCrossSigningReady === "function"
|
||||
? await crypto.isCrossSigningReady()
|
||||
: true;
|
||||
if (crossSigningReady) {
|
||||
await crypto.crossSignDevice(deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
const refreshedStatus =
|
||||
typeof crypto.getDeviceVerificationStatus === "function"
|
||||
? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null)
|
||||
: null;
|
||||
const verified =
|
||||
refreshedStatus?.isVerified?.() === true ||
|
||||
refreshedStatus?.localVerified === true ||
|
||||
refreshedStatus?.crossSigningVerified === true ||
|
||||
refreshedStatus?.signedByOwner === true;
|
||||
if (!verified && strict) {
|
||||
throw new Error(`Matrix own device ${deviceId} is not verified after bootstrap`);
|
||||
}
|
||||
return verified;
|
||||
}
|
||||
}
|
||||
131
extensions/matrix-js/src/matrix/sdk/crypto-facade.test.ts
Normal file
131
extensions/matrix-js/src/matrix/sdk/crypto-facade.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createMatrixCryptoFacade } from "./crypto-facade.js";
|
||||
import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
|
||||
import type { MatrixVerificationManager } from "./verification-manager.js";
|
||||
|
||||
describe("createMatrixCryptoFacade", () => {
|
||||
it("detects encrypted rooms from cached room state", async () => {
|
||||
const facade = createMatrixCryptoFacade({
|
||||
client: {
|
||||
getRoom: () => ({
|
||||
hasEncryptionStateEvent: () => true,
|
||||
}),
|
||||
getCrypto: () => undefined,
|
||||
},
|
||||
verificationManager: {
|
||||
requestOwnUserVerification: vi.fn(),
|
||||
listVerifications: vi.fn(async () => []),
|
||||
requestVerification: vi.fn(),
|
||||
acceptVerification: vi.fn(),
|
||||
cancelVerification: vi.fn(),
|
||||
startVerification: vi.fn(),
|
||||
generateVerificationQr: vi.fn(),
|
||||
scanVerificationQr: vi.fn(),
|
||||
confirmVerificationSas: vi.fn(),
|
||||
mismatchVerificationSas: vi.fn(),
|
||||
confirmVerificationReciprocateQr: vi.fn(),
|
||||
getVerificationSas: vi.fn(),
|
||||
} as unknown as MatrixVerificationManager,
|
||||
recoveryKeyStore: {
|
||||
getRecoveryKeySummary: vi.fn(() => null),
|
||||
} as unknown as MatrixRecoveryKeyStore,
|
||||
getRoomStateEvent: vi.fn(async () => ({ algorithm: "m.megolm.v1.aes-sha2" })),
|
||||
downloadContent: vi.fn(async () => Buffer.alloc(0)),
|
||||
});
|
||||
|
||||
await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to server room state when room cache has no encryption event", async () => {
|
||||
const getRoomStateEvent = vi.fn(async () => ({
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
}));
|
||||
const facade = createMatrixCryptoFacade({
|
||||
client: {
|
||||
getRoom: () => ({
|
||||
hasEncryptionStateEvent: () => false,
|
||||
}),
|
||||
getCrypto: () => undefined,
|
||||
},
|
||||
verificationManager: {
|
||||
requestOwnUserVerification: vi.fn(),
|
||||
listVerifications: vi.fn(async () => []),
|
||||
requestVerification: vi.fn(),
|
||||
acceptVerification: vi.fn(),
|
||||
cancelVerification: vi.fn(),
|
||||
startVerification: vi.fn(),
|
||||
generateVerificationQr: vi.fn(),
|
||||
scanVerificationQr: vi.fn(),
|
||||
confirmVerificationSas: vi.fn(),
|
||||
mismatchVerificationSas: vi.fn(),
|
||||
confirmVerificationReciprocateQr: vi.fn(),
|
||||
getVerificationSas: vi.fn(),
|
||||
} as unknown as MatrixVerificationManager,
|
||||
recoveryKeyStore: {
|
||||
getRecoveryKeySummary: vi.fn(() => null),
|
||||
} as unknown as MatrixRecoveryKeyStore,
|
||||
getRoomStateEvent,
|
||||
downloadContent: vi.fn(async () => Buffer.alloc(0)),
|
||||
});
|
||||
|
||||
await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true);
|
||||
expect(getRoomStateEvent).toHaveBeenCalledWith("!room:example.org", "m.room.encryption", "");
|
||||
});
|
||||
|
||||
it("forwards verification requests and uses client crypto API", async () => {
|
||||
const crypto = { requestOwnUserVerification: vi.fn(async () => null) };
|
||||
const requestVerification = vi.fn(async () => ({
|
||||
id: "verification-1",
|
||||
otherUserId: "@alice:example.org",
|
||||
isSelfVerification: false,
|
||||
initiatedByMe: true,
|
||||
phase: 2,
|
||||
phaseName: "ready",
|
||||
pending: true,
|
||||
methods: ["m.sas.v1"],
|
||||
canAccept: false,
|
||||
hasSas: false,
|
||||
hasReciprocateQr: false,
|
||||
completed: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
const facade = createMatrixCryptoFacade({
|
||||
client: {
|
||||
getRoom: () => null,
|
||||
getCrypto: () => crypto,
|
||||
},
|
||||
verificationManager: {
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
listVerifications: vi.fn(async () => []),
|
||||
requestVerification,
|
||||
acceptVerification: vi.fn(),
|
||||
cancelVerification: vi.fn(),
|
||||
startVerification: vi.fn(),
|
||||
generateVerificationQr: vi.fn(),
|
||||
scanVerificationQr: vi.fn(),
|
||||
confirmVerificationSas: vi.fn(),
|
||||
mismatchVerificationSas: vi.fn(),
|
||||
confirmVerificationReciprocateQr: vi.fn(),
|
||||
getVerificationSas: vi.fn(),
|
||||
} as unknown as MatrixVerificationManager,
|
||||
recoveryKeyStore: {
|
||||
getRecoveryKeySummary: vi.fn(() => ({ keyId: "KEY" })),
|
||||
} as unknown as MatrixRecoveryKeyStore,
|
||||
getRoomStateEvent: vi.fn(async () => ({})),
|
||||
downloadContent: vi.fn(async () => Buffer.alloc(0)),
|
||||
});
|
||||
|
||||
const result = await facade.requestVerification({
|
||||
userId: "@alice:example.org",
|
||||
deviceId: "DEVICE",
|
||||
});
|
||||
|
||||
expect(requestVerification).toHaveBeenCalledWith(crypto, {
|
||||
userId: "@alice:example.org",
|
||||
deviceId: "DEVICE",
|
||||
});
|
||||
expect(result.id).toBe("verification-1");
|
||||
await expect(facade.getRecoveryKey()).resolves.toMatchObject({ keyId: "KEY" });
|
||||
});
|
||||
});
|
||||
173
extensions/matrix-js/src/matrix/sdk/crypto-facade.ts
Normal file
173
extensions/matrix-js/src/matrix/sdk/crypto-facade.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { Attachment, EncryptedAttachment } from "@matrix-org/matrix-sdk-crypto-nodejs";
|
||||
import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
|
||||
import type { EncryptedFile } from "./types.js";
|
||||
import type {
|
||||
MatrixVerificationCryptoApi,
|
||||
MatrixVerificationManager,
|
||||
MatrixVerificationMethod,
|
||||
MatrixVerificationSummary,
|
||||
} from "./verification-manager.js";
|
||||
|
||||
type MatrixCryptoFacadeClient = {
|
||||
getRoom: (roomId: string) => { hasEncryptionStateEvent: () => boolean } | null;
|
||||
getCrypto: () => unknown;
|
||||
};
|
||||
|
||||
export type MatrixCryptoFacade = {
|
||||
prepare: (joinedRooms: string[]) => Promise<void>;
|
||||
updateSyncData: (
|
||||
toDeviceMessages: unknown,
|
||||
otkCounts: unknown,
|
||||
unusedFallbackKeyAlgs: unknown,
|
||||
changedDeviceLists: unknown,
|
||||
leftDeviceLists: unknown,
|
||||
) => Promise<void>;
|
||||
isRoomEncrypted: (roomId: string) => Promise<boolean>;
|
||||
requestOwnUserVerification: () => Promise<unknown | null>;
|
||||
encryptMedia: (buffer: Buffer) => Promise<{ buffer: Buffer; file: Omit<EncryptedFile, "url"> }>;
|
||||
decryptMedia: (file: EncryptedFile) => Promise<Buffer>;
|
||||
getRecoveryKey: () => Promise<{
|
||||
encodedPrivateKey?: string;
|
||||
keyId?: string | null;
|
||||
createdAt?: string;
|
||||
} | null>;
|
||||
listVerifications: () => Promise<MatrixVerificationSummary[]>;
|
||||
requestVerification: (params: {
|
||||
ownUser?: boolean;
|
||||
userId?: string;
|
||||
deviceId?: string;
|
||||
roomId?: string;
|
||||
}) => Promise<MatrixVerificationSummary>;
|
||||
acceptVerification: (id: string) => Promise<MatrixVerificationSummary>;
|
||||
cancelVerification: (
|
||||
id: string,
|
||||
params?: { reason?: string; code?: string },
|
||||
) => Promise<MatrixVerificationSummary>;
|
||||
startVerification: (
|
||||
id: string,
|
||||
method?: MatrixVerificationMethod,
|
||||
) => Promise<MatrixVerificationSummary>;
|
||||
generateVerificationQr: (id: string) => Promise<{ qrDataBase64: string }>;
|
||||
scanVerificationQr: (id: string, qrDataBase64: string) => Promise<MatrixVerificationSummary>;
|
||||
confirmVerificationSas: (id: string) => Promise<MatrixVerificationSummary>;
|
||||
mismatchVerificationSas: (id: string) => Promise<MatrixVerificationSummary>;
|
||||
confirmVerificationReciprocateQr: (id: string) => Promise<MatrixVerificationSummary>;
|
||||
getVerificationSas: (
|
||||
id: string,
|
||||
) => Promise<{ decimal?: [number, number, number]; emoji?: Array<[string, string]> }>;
|
||||
};
|
||||
|
||||
export function createMatrixCryptoFacade(deps: {
|
||||
client: MatrixCryptoFacadeClient;
|
||||
verificationManager: MatrixVerificationManager;
|
||||
recoveryKeyStore: MatrixRecoveryKeyStore;
|
||||
getRoomStateEvent: (
|
||||
roomId: string,
|
||||
eventType: string,
|
||||
stateKey?: string,
|
||||
) => Promise<Record<string, unknown>>;
|
||||
downloadContent: (mxcUrl: string) => Promise<Buffer>;
|
||||
}): MatrixCryptoFacade {
|
||||
return {
|
||||
prepare: async (_joinedRooms: string[]) => {
|
||||
// matrix-js-sdk performs crypto prep during startup; no extra work required here.
|
||||
},
|
||||
updateSyncData: async (
|
||||
_toDeviceMessages: unknown,
|
||||
_otkCounts: unknown,
|
||||
_unusedFallbackKeyAlgs: unknown,
|
||||
_changedDeviceLists: unknown,
|
||||
_leftDeviceLists: unknown,
|
||||
) => {
|
||||
// compatibility no-op
|
||||
},
|
||||
isRoomEncrypted: async (roomId: string): Promise<boolean> => {
|
||||
const room = deps.client.getRoom(roomId);
|
||||
if (room?.hasEncryptionStateEvent()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const event = await deps.getRoomStateEvent(roomId, "m.room.encryption", "");
|
||||
return typeof event.algorithm === "string" && event.algorithm.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
requestOwnUserVerification: async (): Promise<unknown | null> => {
|
||||
const crypto = deps.client.getCrypto() as MatrixVerificationCryptoApi | undefined;
|
||||
return await deps.verificationManager.requestOwnUserVerification(crypto);
|
||||
},
|
||||
encryptMedia: async (
|
||||
buffer: Buffer,
|
||||
): Promise<{ buffer: Buffer; file: Omit<EncryptedFile, "url"> }> => {
|
||||
const encrypted = Attachment.encrypt(new Uint8Array(buffer));
|
||||
const mediaInfoJson = encrypted.mediaEncryptionInfo;
|
||||
if (!mediaInfoJson) {
|
||||
throw new Error("Matrix media encryption failed: missing media encryption info");
|
||||
}
|
||||
const parsed = JSON.parse(mediaInfoJson) as EncryptedFile;
|
||||
return {
|
||||
buffer: Buffer.from(encrypted.encryptedData),
|
||||
file: {
|
||||
key: parsed.key,
|
||||
iv: parsed.iv,
|
||||
hashes: parsed.hashes,
|
||||
v: parsed.v,
|
||||
},
|
||||
};
|
||||
},
|
||||
decryptMedia: async (file: EncryptedFile): Promise<Buffer> => {
|
||||
const encrypted = await deps.downloadContent(file.url);
|
||||
const metadata: EncryptedFile = {
|
||||
url: file.url,
|
||||
key: file.key,
|
||||
iv: file.iv,
|
||||
hashes: file.hashes,
|
||||
v: file.v,
|
||||
};
|
||||
const attachment = new EncryptedAttachment(
|
||||
new Uint8Array(encrypted),
|
||||
JSON.stringify(metadata),
|
||||
);
|
||||
const decrypted = Attachment.decrypt(attachment);
|
||||
return Buffer.from(decrypted);
|
||||
},
|
||||
getRecoveryKey: async () => {
|
||||
return deps.recoveryKeyStore.getRecoveryKeySummary();
|
||||
},
|
||||
listVerifications: async () => {
|
||||
return deps.verificationManager.listVerifications();
|
||||
},
|
||||
requestVerification: async (params) => {
|
||||
const crypto = deps.client.getCrypto() as MatrixVerificationCryptoApi | undefined;
|
||||
return await deps.verificationManager.requestVerification(crypto, params);
|
||||
},
|
||||
acceptVerification: async (id) => {
|
||||
return await deps.verificationManager.acceptVerification(id);
|
||||
},
|
||||
cancelVerification: async (id, params) => {
|
||||
return await deps.verificationManager.cancelVerification(id, params);
|
||||
},
|
||||
startVerification: async (id, method = "sas") => {
|
||||
return await deps.verificationManager.startVerification(id, method);
|
||||
},
|
||||
generateVerificationQr: async (id) => {
|
||||
return await deps.verificationManager.generateVerificationQr(id);
|
||||
},
|
||||
scanVerificationQr: async (id, qrDataBase64) => {
|
||||
return await deps.verificationManager.scanVerificationQr(id, qrDataBase64);
|
||||
},
|
||||
confirmVerificationSas: async (id) => {
|
||||
return await deps.verificationManager.confirmVerificationSas(id);
|
||||
},
|
||||
mismatchVerificationSas: async (id) => {
|
||||
return deps.verificationManager.mismatchVerificationSas(id);
|
||||
},
|
||||
confirmVerificationReciprocateQr: async (id) => {
|
||||
return deps.verificationManager.confirmVerificationReciprocateQr(id);
|
||||
},
|
||||
getVerificationSas: async (id) => {
|
||||
return deps.verificationManager.getVerificationSas(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
307
extensions/matrix-js/src/matrix/sdk/decrypt-bridge.ts
Normal file
307
extensions/matrix-js/src/matrix/sdk/decrypt-bridge.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { MatrixEventEvent, type MatrixEvent } from "matrix-js-sdk";
|
||||
import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js";
|
||||
import { LogService, noop } from "./logger.js";
|
||||
|
||||
type MatrixDecryptIfNeededClient = {
|
||||
decryptEventIfNeeded?: (
|
||||
event: MatrixEvent,
|
||||
opts?: {
|
||||
isRetry?: boolean;
|
||||
},
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
type MatrixDecryptRetryState = {
|
||||
event: MatrixEvent;
|
||||
roomId: string;
|
||||
eventId: string;
|
||||
attempts: number;
|
||||
inFlight: boolean;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
type DecryptBridgeRawEvent = {
|
||||
event_id: string;
|
||||
};
|
||||
|
||||
type MatrixCryptoRetrySignalSource = {
|
||||
on: (eventName: string, listener: (...args: unknown[]) => void) => void;
|
||||
};
|
||||
|
||||
const MATRIX_DECRYPT_RETRY_BASE_DELAY_MS = 1_500;
|
||||
const MATRIX_DECRYPT_RETRY_MAX_DELAY_MS = 30_000;
|
||||
const MATRIX_DECRYPT_RETRY_MAX_ATTEMPTS = 8;
|
||||
|
||||
function resolveDecryptRetryKey(roomId: string, eventId: string): string | null {
|
||||
if (!roomId || !eventId) {
|
||||
return null;
|
||||
}
|
||||
return `${roomId}|${eventId}`;
|
||||
}
|
||||
|
||||
function isDecryptionFailure(event: MatrixEvent): boolean {
|
||||
return (
|
||||
typeof (event as { isDecryptionFailure?: () => boolean }).isDecryptionFailure === "function" &&
|
||||
(event as { isDecryptionFailure: () => boolean }).isDecryptionFailure()
|
||||
);
|
||||
}
|
||||
|
||||
export class MatrixDecryptBridge<TRawEvent extends DecryptBridgeRawEvent> {
|
||||
private readonly trackedEncryptedEvents = new WeakSet<object>();
|
||||
private readonly decryptedMessageDedupe = new Map<string, number>();
|
||||
private readonly decryptRetries = new Map<string, MatrixDecryptRetryState>();
|
||||
private readonly failedDecryptionsNotified = new Set<string>();
|
||||
private cryptoRetrySignalsBound = false;
|
||||
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
client: MatrixDecryptIfNeededClient;
|
||||
toRaw: (event: MatrixEvent) => TRawEvent;
|
||||
emitDecryptedEvent: (roomId: string, event: TRawEvent) => void;
|
||||
emitMessage: (roomId: string, event: TRawEvent) => void;
|
||||
emitFailedDecryption: (roomId: string, event: TRawEvent, error: Error) => void;
|
||||
},
|
||||
) {}
|
||||
|
||||
shouldEmitUnencryptedMessage(roomId: string, eventId: string): boolean {
|
||||
if (!eventId) {
|
||||
return true;
|
||||
}
|
||||
const key = `${roomId}|${eventId}`;
|
||||
const createdAt = this.decryptedMessageDedupe.get(key);
|
||||
if (createdAt === undefined) {
|
||||
return true;
|
||||
}
|
||||
this.decryptedMessageDedupe.delete(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
attachEncryptedEvent(event: MatrixEvent, roomId: string): void {
|
||||
if (this.trackedEncryptedEvents.has(event)) {
|
||||
return;
|
||||
}
|
||||
this.trackedEncryptedEvents.add(event);
|
||||
event.on(MatrixEventEvent.Decrypted, (decryptedEvent: MatrixEvent, err?: Error) => {
|
||||
this.handleEncryptedEventDecrypted({
|
||||
roomId,
|
||||
encryptedEvent: event,
|
||||
decryptedEvent,
|
||||
err,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
retryPendingNow(reason: string): void {
|
||||
const pending = Array.from(this.decryptRetries.entries());
|
||||
if (pending.length === 0) {
|
||||
return;
|
||||
}
|
||||
LogService.debug("MatrixClientLite", `Retrying pending decryptions due to ${reason}`);
|
||||
for (const [retryKey, state] of pending) {
|
||||
if (state.timer) {
|
||||
clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
if (state.inFlight) {
|
||||
continue;
|
||||
}
|
||||
this.runDecryptRetry(retryKey).catch(noop);
|
||||
}
|
||||
}
|
||||
|
||||
bindCryptoRetrySignals(crypto: MatrixCryptoRetrySignalSource | undefined): void {
|
||||
if (!crypto || this.cryptoRetrySignalsBound) {
|
||||
return;
|
||||
}
|
||||
this.cryptoRetrySignalsBound = true;
|
||||
|
||||
const trigger = (reason: string): void => {
|
||||
this.retryPendingNow(reason);
|
||||
};
|
||||
|
||||
crypto.on(CryptoEvent.KeyBackupDecryptionKeyCached, () => {
|
||||
trigger("crypto.keyBackupDecryptionKeyCached");
|
||||
});
|
||||
crypto.on(CryptoEvent.RehydrationCompleted, () => {
|
||||
trigger("dehydration.RehydrationCompleted");
|
||||
});
|
||||
crypto.on(CryptoEvent.DevicesUpdated, () => {
|
||||
trigger("crypto.devicesUpdated");
|
||||
});
|
||||
crypto.on(CryptoEvent.KeysChanged, () => {
|
||||
trigger("crossSigning.keysChanged");
|
||||
});
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
for (const retryKey of this.decryptRetries.keys()) {
|
||||
this.clearDecryptRetry(retryKey);
|
||||
}
|
||||
}
|
||||
|
||||
private handleEncryptedEventDecrypted(params: {
|
||||
roomId: string;
|
||||
encryptedEvent: MatrixEvent;
|
||||
decryptedEvent: MatrixEvent;
|
||||
err?: Error;
|
||||
}): void {
|
||||
const decryptedRoomId = params.decryptedEvent.getRoomId() || params.roomId;
|
||||
const decryptedRaw = this.deps.toRaw(params.decryptedEvent);
|
||||
const retryEventId = decryptedRaw.event_id || params.encryptedEvent.getId() || "";
|
||||
const retryKey = resolveDecryptRetryKey(decryptedRoomId, retryEventId);
|
||||
|
||||
if (params.err) {
|
||||
this.emitFailedDecryptionOnce(retryKey, decryptedRoomId, decryptedRaw, params.err);
|
||||
this.scheduleDecryptRetry({
|
||||
event: params.encryptedEvent,
|
||||
roomId: decryptedRoomId,
|
||||
eventId: retryEventId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDecryptionFailure(params.decryptedEvent)) {
|
||||
this.emitFailedDecryptionOnce(
|
||||
retryKey,
|
||||
decryptedRoomId,
|
||||
decryptedRaw,
|
||||
new Error("Matrix event failed to decrypt"),
|
||||
);
|
||||
this.scheduleDecryptRetry({
|
||||
event: params.encryptedEvent,
|
||||
roomId: decryptedRoomId,
|
||||
eventId: retryEventId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (retryKey) {
|
||||
this.clearDecryptRetry(retryKey);
|
||||
}
|
||||
this.rememberDecryptedMessage(decryptedRoomId, decryptedRaw.event_id);
|
||||
this.deps.emitDecryptedEvent(decryptedRoomId, decryptedRaw);
|
||||
this.deps.emitMessage(decryptedRoomId, decryptedRaw);
|
||||
}
|
||||
|
||||
private emitFailedDecryptionOnce(
|
||||
retryKey: string | null,
|
||||
roomId: string,
|
||||
event: TRawEvent,
|
||||
error: Error,
|
||||
): void {
|
||||
if (retryKey) {
|
||||
if (this.failedDecryptionsNotified.has(retryKey)) {
|
||||
return;
|
||||
}
|
||||
this.failedDecryptionsNotified.add(retryKey);
|
||||
}
|
||||
this.deps.emitFailedDecryption(roomId, event, error);
|
||||
}
|
||||
|
||||
private scheduleDecryptRetry(params: {
|
||||
event: MatrixEvent;
|
||||
roomId: string;
|
||||
eventId: string;
|
||||
}): void {
|
||||
const retryKey = resolveDecryptRetryKey(params.roomId, params.eventId);
|
||||
if (!retryKey) {
|
||||
return;
|
||||
}
|
||||
const existing = this.decryptRetries.get(retryKey);
|
||||
if (existing?.timer || existing?.inFlight) {
|
||||
return;
|
||||
}
|
||||
const attempts = (existing?.attempts ?? 0) + 1;
|
||||
if (attempts > MATRIX_DECRYPT_RETRY_MAX_ATTEMPTS) {
|
||||
this.clearDecryptRetry(retryKey);
|
||||
LogService.debug(
|
||||
"MatrixClientLite",
|
||||
`Giving up decryption retry for ${params.eventId} in ${params.roomId} after ${attempts - 1} attempts`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const delayMs = Math.min(
|
||||
MATRIX_DECRYPT_RETRY_BASE_DELAY_MS * 2 ** (attempts - 1),
|
||||
MATRIX_DECRYPT_RETRY_MAX_DELAY_MS,
|
||||
);
|
||||
const next: MatrixDecryptRetryState = {
|
||||
event: params.event,
|
||||
roomId: params.roomId,
|
||||
eventId: params.eventId,
|
||||
attempts,
|
||||
inFlight: false,
|
||||
timer: null,
|
||||
};
|
||||
next.timer = setTimeout(() => {
|
||||
this.runDecryptRetry(retryKey).catch(noop);
|
||||
}, delayMs);
|
||||
this.decryptRetries.set(retryKey, next);
|
||||
}
|
||||
|
||||
private async runDecryptRetry(retryKey: string): Promise<void> {
|
||||
const state = this.decryptRetries.get(retryKey);
|
||||
if (!state || state.inFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.inFlight = true;
|
||||
state.timer = null;
|
||||
const canDecrypt = typeof this.deps.client.decryptEventIfNeeded === "function";
|
||||
if (!canDecrypt) {
|
||||
this.clearDecryptRetry(retryKey);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.deps.client.decryptEventIfNeeded?.(state.event, {
|
||||
isRetry: true,
|
||||
});
|
||||
} catch {
|
||||
// Retry with backoff until we hit the configured retry cap.
|
||||
} finally {
|
||||
state.inFlight = false;
|
||||
}
|
||||
|
||||
if (isDecryptionFailure(state.event)) {
|
||||
this.scheduleDecryptRetry(state);
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearDecryptRetry(retryKey);
|
||||
}
|
||||
|
||||
private clearDecryptRetry(retryKey: string): void {
|
||||
const state = this.decryptRetries.get(retryKey);
|
||||
if (state?.timer) {
|
||||
clearTimeout(state.timer);
|
||||
}
|
||||
this.decryptRetries.delete(retryKey);
|
||||
this.failedDecryptionsNotified.delete(retryKey);
|
||||
}
|
||||
|
||||
private rememberDecryptedMessage(roomId: string, eventId: string): void {
|
||||
if (!eventId) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
this.pruneDecryptedMessageDedupe(now);
|
||||
this.decryptedMessageDedupe.set(`${roomId}|${eventId}`, now);
|
||||
}
|
||||
|
||||
private pruneDecryptedMessageDedupe(now: number): void {
|
||||
const ttlMs = 30_000;
|
||||
for (const [key, createdAt] of this.decryptedMessageDedupe) {
|
||||
if (now - createdAt > ttlMs) {
|
||||
this.decryptedMessageDedupe.delete(key);
|
||||
}
|
||||
}
|
||||
const maxEntries = 2048;
|
||||
while (this.decryptedMessageDedupe.size > maxEntries) {
|
||||
const oldest = this.decryptedMessageDedupe.keys().next().value;
|
||||
if (oldest === undefined) {
|
||||
break;
|
||||
}
|
||||
this.decryptedMessageDedupe.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
60
extensions/matrix-js/src/matrix/sdk/event-helpers.test.ts
Normal file
60
extensions/matrix-js/src/matrix/sdk/event-helpers.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { MatrixEvent } from "matrix-js-sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildHttpError, matrixEventToRaw, parseMxc } from "./event-helpers.js";
|
||||
|
||||
describe("event-helpers", () => {
|
||||
it("parses mxc URIs", () => {
|
||||
expect(parseMxc("mxc://server.example/media-id")).toEqual({
|
||||
server: "server.example",
|
||||
mediaId: "media-id",
|
||||
});
|
||||
expect(parseMxc("not-mxc")).toBeNull();
|
||||
});
|
||||
|
||||
it("builds HTTP errors from JSON and plain text payloads", () => {
|
||||
const fromJson = buildHttpError(403, JSON.stringify({ error: "forbidden" }));
|
||||
expect(fromJson.message).toBe("forbidden");
|
||||
expect(fromJson.statusCode).toBe(403);
|
||||
|
||||
const fromText = buildHttpError(500, "internal failure");
|
||||
expect(fromText.message).toBe("internal failure");
|
||||
expect(fromText.statusCode).toBe(500);
|
||||
});
|
||||
|
||||
it("serializes Matrix events and resolves state key from available sources", () => {
|
||||
const viaGetter = {
|
||||
getId: () => "$1",
|
||||
getSender: () => "@alice:example.org",
|
||||
getType: () => "m.room.member",
|
||||
getTs: () => 1000,
|
||||
getContent: () => ({ membership: "join" }),
|
||||
getUnsigned: () => ({ age: 1 }),
|
||||
getStateKey: () => "@alice:example.org",
|
||||
} as unknown as MatrixEvent;
|
||||
expect(matrixEventToRaw(viaGetter).state_key).toBe("@alice:example.org");
|
||||
|
||||
const viaWire = {
|
||||
getId: () => "$2",
|
||||
getSender: () => "@bob:example.org",
|
||||
getType: () => "m.room.member",
|
||||
getTs: () => 2000,
|
||||
getContent: () => ({ membership: "join" }),
|
||||
getUnsigned: () => ({}),
|
||||
getStateKey: () => undefined,
|
||||
getWireContent: () => ({ state_key: "@bob:example.org" }),
|
||||
} as unknown as MatrixEvent;
|
||||
expect(matrixEventToRaw(viaWire).state_key).toBe("@bob:example.org");
|
||||
|
||||
const viaRaw = {
|
||||
getId: () => "$3",
|
||||
getSender: () => "@carol:example.org",
|
||||
getType: () => "m.room.member",
|
||||
getTs: () => 3000,
|
||||
getContent: () => ({ membership: "join" }),
|
||||
getUnsigned: () => ({}),
|
||||
getStateKey: () => undefined,
|
||||
event: { state_key: "@carol:example.org" },
|
||||
} as unknown as MatrixEvent;
|
||||
expect(matrixEventToRaw(viaRaw).state_key).toBe("@carol:example.org");
|
||||
});
|
||||
});
|
||||
71
extensions/matrix-js/src/matrix/sdk/event-helpers.ts
Normal file
71
extensions/matrix-js/src/matrix/sdk/event-helpers.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { MatrixEvent } from "matrix-js-sdk";
|
||||
import type { MatrixRawEvent } from "./types.js";
|
||||
|
||||
export function matrixEventToRaw(event: MatrixEvent): MatrixRawEvent {
|
||||
const unsigned = (event.getUnsigned?.() ?? {}) as {
|
||||
age?: number;
|
||||
redacted_because?: unknown;
|
||||
};
|
||||
const raw: MatrixRawEvent = {
|
||||
event_id: event.getId() ?? "",
|
||||
sender: event.getSender() ?? "",
|
||||
type: event.getType() ?? "",
|
||||
origin_server_ts: event.getTs() ?? 0,
|
||||
content: ((event.getContent?.() ?? {}) as Record<string, unknown>) || {},
|
||||
unsigned,
|
||||
};
|
||||
const stateKey = resolveMatrixStateKey(event);
|
||||
if (typeof stateKey === "string") {
|
||||
raw.state_key = stateKey;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function parseMxc(url: string): { server: string; mediaId: string } | null {
|
||||
const match = /^mxc:\/\/([^/]+)\/(.+)$/.exec(url.trim());
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
server: match[1],
|
||||
mediaId: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildHttpError(
|
||||
statusCode: number,
|
||||
bodyText: string,
|
||||
): Error & { statusCode: number } {
|
||||
let message = `Matrix HTTP ${statusCode}`;
|
||||
if (bodyText.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(bodyText) as { error?: string };
|
||||
if (typeof parsed.error === "string" && parsed.error.trim()) {
|
||||
message = parsed.error.trim();
|
||||
} else {
|
||||
message = bodyText.slice(0, 500);
|
||||
}
|
||||
} catch {
|
||||
message = bodyText.slice(0, 500);
|
||||
}
|
||||
}
|
||||
return Object.assign(new Error(message), { statusCode });
|
||||
}
|
||||
|
||||
function resolveMatrixStateKey(event: MatrixEvent): string | undefined {
|
||||
const direct = event.getStateKey?.();
|
||||
if (typeof direct === "string") {
|
||||
return direct;
|
||||
}
|
||||
const wireContent = (
|
||||
event as { getWireContent?: () => { state_key?: unknown } }
|
||||
).getWireContent?.();
|
||||
if (wireContent && typeof wireContent.state_key === "string") {
|
||||
return wireContent.state_key;
|
||||
}
|
||||
const rawEvent = (event as { event?: { state_key?: unknown } }).event;
|
||||
if (rawEvent && typeof rawEvent.state_key === "string") {
|
||||
return rawEvent.state_key;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
106
extensions/matrix-js/src/matrix/sdk/http-client.test.ts
Normal file
106
extensions/matrix-js/src/matrix/sdk/http-client.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { performMatrixRequestMock } = vi.hoisted(() => ({
|
||||
performMatrixRequestMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./transport.js", () => ({
|
||||
performMatrixRequest: performMatrixRequestMock,
|
||||
}));
|
||||
|
||||
import { MatrixAuthedHttpClient } from "./http-client.js";
|
||||
|
||||
describe("MatrixAuthedHttpClient", () => {
|
||||
beforeEach(() => {
|
||||
performMatrixRequestMock.mockReset();
|
||||
});
|
||||
|
||||
it("parses JSON responses and forwards absolute-endpoint opt-in", async () => {
|
||||
performMatrixRequestMock.mockResolvedValue({
|
||||
response: new Response('{"ok":true}', {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
text: '{"ok":true}',
|
||||
buffer: Buffer.from('{"ok":true}', "utf8"),
|
||||
});
|
||||
|
||||
const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token");
|
||||
const result = await client.requestJson({
|
||||
method: "GET",
|
||||
endpoint: "https://matrix.example.org/_matrix/client/v3/account/whoami",
|
||||
timeoutMs: 5000,
|
||||
allowAbsoluteEndpoint: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(performMatrixRequestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
endpoint: "https://matrix.example.org/_matrix/client/v3/account/whoami",
|
||||
allowAbsoluteEndpoint: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns plain text when response is not JSON", async () => {
|
||||
performMatrixRequestMock.mockResolvedValue({
|
||||
response: new Response("pong", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/plain" },
|
||||
}),
|
||||
text: "pong",
|
||||
buffer: Buffer.from("pong", "utf8"),
|
||||
});
|
||||
|
||||
const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token");
|
||||
const result = await client.requestJson({
|
||||
method: "GET",
|
||||
endpoint: "/_matrix/client/v3/ping",
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
expect(result).toBe("pong");
|
||||
});
|
||||
|
||||
it("returns raw buffers for media requests", async () => {
|
||||
const payload = Buffer.from([1, 2, 3, 4]);
|
||||
performMatrixRequestMock.mockResolvedValue({
|
||||
response: new Response(payload, { status: 200 }),
|
||||
text: payload.toString("utf8"),
|
||||
buffer: payload,
|
||||
});
|
||||
|
||||
const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token");
|
||||
const result = await client.requestRaw({
|
||||
method: "GET",
|
||||
endpoint: "/_matrix/media/v3/download/example/id",
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
|
||||
it("raises HTTP errors with status code metadata", async () => {
|
||||
performMatrixRequestMock.mockResolvedValue({
|
||||
response: new Response(JSON.stringify({ error: "forbidden" }), {
|
||||
status: 403,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
text: JSON.stringify({ error: "forbidden" }),
|
||||
buffer: Buffer.from(JSON.stringify({ error: "forbidden" }), "utf8"),
|
||||
});
|
||||
|
||||
const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token");
|
||||
await expect(
|
||||
client.requestJson({
|
||||
method: "GET",
|
||||
endpoint: "/_matrix/client/v3/rooms",
|
||||
timeoutMs: 5000,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
message: "forbidden",
|
||||
statusCode: 403,
|
||||
});
|
||||
});
|
||||
});
|
||||
63
extensions/matrix-js/src/matrix/sdk/http-client.ts
Normal file
63
extensions/matrix-js/src/matrix/sdk/http-client.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { buildHttpError } from "./event-helpers.js";
|
||||
import { type HttpMethod, type QueryParams, performMatrixRequest } from "./transport.js";
|
||||
|
||||
export class MatrixAuthedHttpClient {
|
||||
constructor(
|
||||
private readonly homeserver: string,
|
||||
private readonly accessToken: string,
|
||||
) {}
|
||||
|
||||
async requestJson(params: {
|
||||
method: HttpMethod;
|
||||
endpoint: string;
|
||||
qs?: QueryParams;
|
||||
body?: unknown;
|
||||
timeoutMs: number;
|
||||
allowAbsoluteEndpoint?: boolean;
|
||||
}): Promise<unknown> {
|
||||
const { response, text } = await performMatrixRequest({
|
||||
homeserver: this.homeserver,
|
||||
accessToken: this.accessToken,
|
||||
method: params.method,
|
||||
endpoint: params.endpoint,
|
||||
qs: params.qs,
|
||||
body: params.body,
|
||||
timeoutMs: params.timeoutMs,
|
||||
allowAbsoluteEndpoint: params.allowAbsoluteEndpoint,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw buildHttpError(response.status, text);
|
||||
}
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
if (!text.trim()) {
|
||||
return {};
|
||||
}
|
||||
return JSON.parse(text);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
async requestRaw(params: {
|
||||
method: HttpMethod;
|
||||
endpoint: string;
|
||||
qs?: QueryParams;
|
||||
timeoutMs: number;
|
||||
allowAbsoluteEndpoint?: boolean;
|
||||
}): Promise<Buffer> {
|
||||
const { response, buffer } = await performMatrixRequest({
|
||||
homeserver: this.homeserver,
|
||||
accessToken: this.accessToken,
|
||||
method: params.method,
|
||||
endpoint: params.endpoint,
|
||||
qs: params.qs,
|
||||
timeoutMs: params.timeoutMs,
|
||||
raw: true,
|
||||
allowAbsoluteEndpoint: params.allowAbsoluteEndpoint,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw buildHttpError(response.status, buffer.toString("utf8"));
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
164
extensions/matrix-js/src/matrix/sdk/idb-persistence.ts
Normal file
164
extensions/matrix-js/src/matrix/sdk/idb-persistence.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { indexedDB as fakeIndexedDB } from "fake-indexeddb";
|
||||
import { LogService } from "./logger.js";
|
||||
|
||||
type IdbStoreSnapshot = {
|
||||
name: string;
|
||||
keyPath: IDBObjectStoreParameters["keyPath"];
|
||||
autoIncrement: boolean;
|
||||
indexes: { name: string; keyPath: string | string[]; multiEntry: boolean; unique: boolean }[];
|
||||
records: { key: IDBValidKey; value: unknown }[];
|
||||
};
|
||||
|
||||
type IdbDatabaseSnapshot = {
|
||||
name: string;
|
||||
version: number;
|
||||
stores: IdbStoreSnapshot[];
|
||||
};
|
||||
|
||||
function idbReq<T>(req: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function dumpIndexedDatabases(databasePrefix?: string): Promise<IdbDatabaseSnapshot[]> {
|
||||
const idb = fakeIndexedDB;
|
||||
const dbList = await idb.databases();
|
||||
const snapshot: IdbDatabaseSnapshot[] = [];
|
||||
const expectedPrefix = databasePrefix ? `${databasePrefix}::` : null;
|
||||
|
||||
for (const { name, version } of dbList) {
|
||||
if (!name || !version) continue;
|
||||
if (expectedPrefix && !name.startsWith(expectedPrefix)) continue;
|
||||
const db: IDBDatabase = await new Promise((resolve, reject) => {
|
||||
const r = idb.open(name, version);
|
||||
r.onsuccess = () => resolve(r.result);
|
||||
r.onerror = () => reject(r.error);
|
||||
});
|
||||
|
||||
const stores: IdbStoreSnapshot[] = [];
|
||||
for (const storeName of db.objectStoreNames) {
|
||||
const tx = db.transaction(storeName, "readonly");
|
||||
const store = tx.objectStore(storeName);
|
||||
const storeInfo: IdbStoreSnapshot = {
|
||||
name: storeName,
|
||||
keyPath: store.keyPath as IDBObjectStoreParameters["keyPath"],
|
||||
autoIncrement: store.autoIncrement,
|
||||
indexes: [],
|
||||
records: [],
|
||||
};
|
||||
for (const idxName of store.indexNames) {
|
||||
const idx = store.index(idxName);
|
||||
storeInfo.indexes.push({
|
||||
name: idxName,
|
||||
keyPath: idx.keyPath as string | string[],
|
||||
multiEntry: idx.multiEntry,
|
||||
unique: idx.unique,
|
||||
});
|
||||
}
|
||||
const keys = await idbReq(store.getAllKeys());
|
||||
const values = await idbReq(store.getAll());
|
||||
storeInfo.records = keys.map((k, i) => ({ key: k, value: values[i] }));
|
||||
stores.push(storeInfo);
|
||||
}
|
||||
snapshot.push({ name, version, stores });
|
||||
db.close();
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function restoreIndexedDatabases(snapshot: IdbDatabaseSnapshot[]): Promise<void> {
|
||||
const idb = fakeIndexedDB;
|
||||
for (const dbSnap of snapshot) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const r = idb.open(dbSnap.name, dbSnap.version);
|
||||
r.onupgradeneeded = () => {
|
||||
const db = r.result;
|
||||
for (const storeSnap of dbSnap.stores) {
|
||||
const opts: IDBObjectStoreParameters = {};
|
||||
if (storeSnap.keyPath !== null) opts.keyPath = storeSnap.keyPath;
|
||||
if (storeSnap.autoIncrement) opts.autoIncrement = true;
|
||||
const store = db.createObjectStore(storeSnap.name, opts);
|
||||
for (const idx of storeSnap.indexes) {
|
||||
store.createIndex(idx.name, idx.keyPath, {
|
||||
unique: idx.unique,
|
||||
multiEntry: idx.multiEntry,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
r.onsuccess = async () => {
|
||||
try {
|
||||
const db = r.result;
|
||||
for (const storeSnap of dbSnap.stores) {
|
||||
if (storeSnap.records.length === 0) continue;
|
||||
const tx = db.transaction(storeSnap.name, "readwrite");
|
||||
const store = tx.objectStore(storeSnap.name);
|
||||
for (const rec of storeSnap.records) {
|
||||
if (storeSnap.keyPath !== null) {
|
||||
store.put(rec.value);
|
||||
} else {
|
||||
store.put(rec.value, rec.key);
|
||||
}
|
||||
}
|
||||
await new Promise<void>((res) => {
|
||||
tx.oncomplete = () => res();
|
||||
});
|
||||
}
|
||||
db.close();
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
r.onerror = () => reject(r.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDefaultIdbSnapshotPath(): string {
|
||||
const stateDir =
|
||||
process.env.OPENCLAW_STATE_DIR ||
|
||||
process.env.MOLTBOT_STATE_DIR ||
|
||||
path.join(process.env.HOME || "/tmp", ".openclaw");
|
||||
return path.join(stateDir, "credentials", "matrix-js", "crypto-idb-snapshot.json");
|
||||
}
|
||||
|
||||
export async function restoreIdbFromDisk(snapshotPath?: string): Promise<boolean> {
|
||||
const resolvedPath = snapshotPath ?? resolveDefaultIdbSnapshotPath();
|
||||
try {
|
||||
const data = fs.readFileSync(resolvedPath, "utf8");
|
||||
const snapshot: IdbDatabaseSnapshot[] = JSON.parse(data);
|
||||
if (!Array.isArray(snapshot) || snapshot.length === 0) return false;
|
||||
await restoreIndexedDatabases(snapshot);
|
||||
LogService.info(
|
||||
"IdbPersistence",
|
||||
`Restored ${snapshot.length} IndexedDB database(s) from ${resolvedPath}`,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function persistIdbToDisk(params?: {
|
||||
snapshotPath?: string;
|
||||
databasePrefix?: string;
|
||||
}): Promise<void> {
|
||||
const snapshotPath = params?.snapshotPath ?? resolveDefaultIdbSnapshotPath();
|
||||
try {
|
||||
const snapshot = await dumpIndexedDatabases(params?.databasePrefix);
|
||||
if (snapshot.length === 0) return;
|
||||
fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
|
||||
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot));
|
||||
LogService.debug(
|
||||
"IdbPersistence",
|
||||
`Persisted ${snapshot.length} IndexedDB database(s) to ${snapshotPath}`,
|
||||
);
|
||||
} catch (err) {
|
||||
LogService.warn("IdbPersistence", "Failed to persist IndexedDB snapshot:", err);
|
||||
}
|
||||
}
|
||||
98
extensions/matrix-js/src/matrix/sdk/logger.ts
Normal file
98
extensions/matrix-js/src/matrix/sdk/logger.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { format } from "node:util";
|
||||
import type { RuntimeLogger } from "openclaw/plugin-sdk/matrix-js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
|
||||
export type Logger = {
|
||||
trace: (module: string, ...messageOrObject: unknown[]) => void;
|
||||
debug: (module: string, ...messageOrObject: unknown[]) => void;
|
||||
info: (module: string, ...messageOrObject: unknown[]) => void;
|
||||
warn: (module: string, ...messageOrObject: unknown[]) => void;
|
||||
error: (module: string, ...messageOrObject: unknown[]) => void;
|
||||
};
|
||||
|
||||
export function noop(): void {
|
||||
// no-op
|
||||
}
|
||||
|
||||
function resolveRuntimeLogger(module: string): RuntimeLogger | null {
|
||||
try {
|
||||
return getMatrixRuntime().logging.getChildLogger({ module: `matrix-js:${module}` });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatMessage(module: string, messageOrObject: unknown[]): string {
|
||||
if (messageOrObject.length === 0) {
|
||||
return `[${module}]`;
|
||||
}
|
||||
return `[${module}] ${format(...messageOrObject)}`;
|
||||
}
|
||||
|
||||
export class ConsoleLogger {
|
||||
private emit(
|
||||
level: "debug" | "info" | "warn" | "error",
|
||||
module: string,
|
||||
...messageOrObject: unknown[]
|
||||
): void {
|
||||
const runtimeLogger = resolveRuntimeLogger(module);
|
||||
const message = formatMessage(module, messageOrObject);
|
||||
if (runtimeLogger) {
|
||||
if (level === "debug") {
|
||||
runtimeLogger.debug?.(message);
|
||||
return;
|
||||
}
|
||||
runtimeLogger[level](message);
|
||||
return;
|
||||
}
|
||||
if (level === "debug") {
|
||||
console.debug(message);
|
||||
return;
|
||||
}
|
||||
console[level](message);
|
||||
}
|
||||
|
||||
trace(module: string, ...messageOrObject: unknown[]): void {
|
||||
this.emit("debug", module, ...messageOrObject);
|
||||
}
|
||||
|
||||
debug(module: string, ...messageOrObject: unknown[]): void {
|
||||
this.emit("debug", module, ...messageOrObject);
|
||||
}
|
||||
|
||||
info(module: string, ...messageOrObject: unknown[]): void {
|
||||
this.emit("info", module, ...messageOrObject);
|
||||
}
|
||||
|
||||
warn(module: string, ...messageOrObject: unknown[]): void {
|
||||
this.emit("warn", module, ...messageOrObject);
|
||||
}
|
||||
|
||||
error(module: string, ...messageOrObject: unknown[]): void {
|
||||
this.emit("error", module, ...messageOrObject);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultLogger = new ConsoleLogger();
|
||||
let activeLogger: Logger = defaultLogger;
|
||||
|
||||
export const LogService = {
|
||||
setLogger(logger: Logger): void {
|
||||
activeLogger = logger;
|
||||
},
|
||||
trace(module: string, ...messageOrObject: unknown[]): void {
|
||||
activeLogger.trace(module, ...messageOrObject);
|
||||
},
|
||||
debug(module: string, ...messageOrObject: unknown[]): void {
|
||||
activeLogger.debug(module, ...messageOrObject);
|
||||
},
|
||||
info(module: string, ...messageOrObject: unknown[]): void {
|
||||
activeLogger.info(module, ...messageOrObject);
|
||||
},
|
||||
warn(module: string, ...messageOrObject: unknown[]): void {
|
||||
activeLogger.warn(module, ...messageOrObject);
|
||||
},
|
||||
error(module: string, ...messageOrObject: unknown[]): void {
|
||||
activeLogger.error(module, ...messageOrObject);
|
||||
},
|
||||
};
|
||||
202
extensions/matrix-js/src/matrix/sdk/recovery-key-store.test.ts
Normal file
202
extensions/matrix-js/src/matrix/sdk/recovery-key-store.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { encodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
|
||||
import type { MatrixCryptoBootstrapApi } from "./types.js";
|
||||
|
||||
function createTempRecoveryKeyPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-recovery-key-store-"));
|
||||
return path.join(dir, "recovery-key.json");
|
||||
}
|
||||
|
||||
describe("MatrixRecoveryKeyStore", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("loads a stored recovery key for requested secret-storage keys", async () => {
|
||||
const recoveryKeyPath = createTempRecoveryKeyPath();
|
||||
fs.writeFileSync(
|
||||
recoveryKeyPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
keyId: "SSSS",
|
||||
privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
|
||||
const callbacks = store.buildCryptoCallbacks();
|
||||
const resolved = await callbacks.getSecretStorageKey?.(
|
||||
{ keys: { SSSS: { name: "test" } } },
|
||||
"m.cross_signing.master",
|
||||
);
|
||||
|
||||
expect(resolved?.[0]).toBe("SSSS");
|
||||
expect(Array.from(resolved?.[1] ?? [])).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("persists cached secret-storage keys with secure file permissions", () => {
|
||||
const recoveryKeyPath = createTempRecoveryKeyPath();
|
||||
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
|
||||
const callbacks = store.buildCryptoCallbacks();
|
||||
|
||||
callbacks.cacheSecretStorageKey?.(
|
||||
"KEY123",
|
||||
{
|
||||
name: "openclaw",
|
||||
},
|
||||
new Uint8Array([9, 8, 7]),
|
||||
);
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
|
||||
keyId?: string;
|
||||
privateKeyBase64?: string;
|
||||
};
|
||||
expect(saved.keyId).toBe("KEY123");
|
||||
expect(saved.privateKeyBase64).toBe(Buffer.from([9, 8, 7]).toString("base64"));
|
||||
|
||||
const mode = fs.statSync(recoveryKeyPath).mode & 0o777;
|
||||
expect(mode).toBe(0o600);
|
||||
});
|
||||
|
||||
it("creates and persists a recovery key when secret storage is missing", async () => {
|
||||
const recoveryKeyPath = createTempRecoveryKeyPath();
|
||||
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
|
||||
const generated = {
|
||||
keyId: "GENERATED",
|
||||
keyInfo: { name: "generated" },
|
||||
privateKey: new Uint8Array([5, 6, 7, 8]),
|
||||
encodedPrivateKey: "encoded-generated-key",
|
||||
};
|
||||
const createRecoveryKeyFromPassphrase = vi.fn(async () => generated);
|
||||
const bootstrapSecretStorage = vi.fn(
|
||||
async (opts?: { createSecretStorageKey?: () => Promise<unknown> }) => {
|
||||
await opts?.createSecretStorageKey?.();
|
||||
},
|
||||
);
|
||||
const crypto = {
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage,
|
||||
createRecoveryKeyFromPassphrase,
|
||||
getSecretStorageStatus: vi.fn(async () => ({ ready: false, defaultKeyId: null })),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
} as unknown as MatrixCryptoBootstrapApi;
|
||||
|
||||
await store.bootstrapSecretStorageWithRecoveryKey(crypto);
|
||||
|
||||
expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1);
|
||||
expect(bootstrapSecretStorage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
setupNewSecretStorage: true,
|
||||
}),
|
||||
);
|
||||
expect(store.getRecoveryKeySummary()).toMatchObject({
|
||||
keyId: "GENERATED",
|
||||
encodedPrivateKey: "encoded-generated-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("rebinds stored recovery key to server default key id when it changes", async () => {
|
||||
const recoveryKeyPath = createTempRecoveryKeyPath();
|
||||
fs.writeFileSync(
|
||||
recoveryKeyPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
keyId: "OLD",
|
||||
privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
|
||||
|
||||
const bootstrapSecretStorage = vi.fn(async () => {});
|
||||
const createRecoveryKeyFromPassphrase = vi.fn(async () => {
|
||||
throw new Error("should not be called");
|
||||
});
|
||||
const crypto = {
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage,
|
||||
createRecoveryKeyFromPassphrase,
|
||||
getSecretStorageStatus: vi.fn(async () => ({ ready: true, defaultKeyId: "NEW" })),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
} as unknown as MatrixCryptoBootstrapApi;
|
||||
|
||||
await store.bootstrapSecretStorageWithRecoveryKey(crypto);
|
||||
|
||||
expect(createRecoveryKeyFromPassphrase).not.toHaveBeenCalled();
|
||||
expect(store.getRecoveryKeySummary()).toMatchObject({
|
||||
keyId: "NEW",
|
||||
});
|
||||
});
|
||||
|
||||
it("recreates secret storage when default key exists but is not usable locally", async () => {
|
||||
const recoveryKeyPath = createTempRecoveryKeyPath();
|
||||
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
|
||||
const generated = {
|
||||
keyId: "RECOVERED",
|
||||
keyInfo: { name: "recovered" },
|
||||
privateKey: new Uint8Array([1, 1, 2, 3]),
|
||||
encodedPrivateKey: "encoded-recovered-key",
|
||||
};
|
||||
const createRecoveryKeyFromPassphrase = vi.fn(async () => generated);
|
||||
const bootstrapSecretStorage = vi.fn(
|
||||
async (opts?: { createSecretStorageKey?: () => Promise<unknown> }) => {
|
||||
await opts?.createSecretStorageKey?.();
|
||||
},
|
||||
);
|
||||
const crypto = {
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage,
|
||||
createRecoveryKeyFromPassphrase,
|
||||
getSecretStorageStatus: vi.fn(async () => ({ ready: false, defaultKeyId: "LEGACY" })),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
} as unknown as MatrixCryptoBootstrapApi;
|
||||
|
||||
await store.bootstrapSecretStorageWithRecoveryKey(crypto);
|
||||
|
||||
expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1);
|
||||
expect(bootstrapSecretStorage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
setupNewSecretStorage: true,
|
||||
}),
|
||||
);
|
||||
expect(store.getRecoveryKeySummary()).toMatchObject({
|
||||
keyId: "RECOVERED",
|
||||
encodedPrivateKey: "encoded-recovered-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("stores an encoded recovery key and decodes its private key material", () => {
|
||||
const recoveryKeyPath = createTempRecoveryKeyPath();
|
||||
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
|
||||
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
|
||||
expect(encoded).toBeTypeOf("string");
|
||||
|
||||
const summary = store.storeEncodedRecoveryKey({
|
||||
encodedPrivateKey: encoded as string,
|
||||
keyId: "SSSSKEY",
|
||||
});
|
||||
|
||||
expect(summary.keyId).toBe("SSSSKEY");
|
||||
expect(summary.encodedPrivateKey).toBe(encoded);
|
||||
const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
|
||||
privateKeyBase64?: string;
|
||||
keyId?: string;
|
||||
};
|
||||
expect(persisted.keyId).toBe("SSSSKEY");
|
||||
expect(
|
||||
Buffer.from(persisted.privateKeyBase64 ?? "", "base64").equals(
|
||||
Buffer.from(Array.from({ length: 32 }, (_, i) => i + 1)),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
294
extensions/matrix-js/src/matrix/sdk/recovery-key-store.ts
Normal file
294
extensions/matrix-js/src/matrix/sdk/recovery-key-store.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { decodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js";
|
||||
import { LogService } from "./logger.js";
|
||||
import type {
|
||||
MatrixCryptoBootstrapApi,
|
||||
MatrixCryptoCallbacks,
|
||||
MatrixGeneratedSecretStorageKey,
|
||||
MatrixSecretStorageStatus,
|
||||
MatrixStoredRecoveryKey,
|
||||
} from "./types.js";
|
||||
|
||||
export class MatrixRecoveryKeyStore {
|
||||
private readonly secretStorageKeyCache = new Map<
|
||||
string,
|
||||
{ key: Uint8Array; keyInfo?: MatrixStoredRecoveryKey["keyInfo"] }
|
||||
>();
|
||||
|
||||
constructor(private readonly recoveryKeyPath?: string) {}
|
||||
|
||||
buildCryptoCallbacks(): MatrixCryptoCallbacks {
|
||||
return {
|
||||
getSecretStorageKey: async ({ keys }) => {
|
||||
const requestedKeyIds = Object.keys(keys ?? {});
|
||||
if (requestedKeyIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const keyId of requestedKeyIds) {
|
||||
const cached = this.secretStorageKeyCache.get(keyId);
|
||||
if (cached) {
|
||||
return [keyId, new Uint8Array(cached.key)];
|
||||
}
|
||||
}
|
||||
|
||||
const stored = this.loadStoredRecoveryKey();
|
||||
if (!stored || !stored.privateKeyBase64) {
|
||||
return null;
|
||||
}
|
||||
const privateKey = new Uint8Array(Buffer.from(stored.privateKeyBase64, "base64"));
|
||||
if (privateKey.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (stored.keyId && requestedKeyIds.includes(stored.keyId)) {
|
||||
this.rememberSecretStorageKey(stored.keyId, privateKey, stored.keyInfo);
|
||||
return [stored.keyId, privateKey];
|
||||
}
|
||||
|
||||
const firstRequestedKeyId = requestedKeyIds[0];
|
||||
if (!firstRequestedKeyId) {
|
||||
return null;
|
||||
}
|
||||
this.rememberSecretStorageKey(firstRequestedKeyId, privateKey, stored.keyInfo);
|
||||
return [firstRequestedKeyId, privateKey];
|
||||
},
|
||||
cacheSecretStorageKey: (keyId, keyInfo, key) => {
|
||||
const privateKey = new Uint8Array(key);
|
||||
const normalizedKeyInfo: MatrixStoredRecoveryKey["keyInfo"] = {
|
||||
passphrase: keyInfo?.passphrase,
|
||||
name: typeof keyInfo?.name === "string" ? keyInfo.name : undefined,
|
||||
};
|
||||
this.rememberSecretStorageKey(keyId, privateKey, normalizedKeyInfo);
|
||||
|
||||
const stored = this.loadStoredRecoveryKey();
|
||||
this.saveRecoveryKeyToDisk({
|
||||
keyId,
|
||||
keyInfo: normalizedKeyInfo,
|
||||
privateKey,
|
||||
encodedPrivateKey: stored?.encodedPrivateKey,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getRecoveryKeySummary(): {
|
||||
encodedPrivateKey?: string;
|
||||
keyId?: string | null;
|
||||
createdAt?: string;
|
||||
} | null {
|
||||
const stored = this.loadStoredRecoveryKey();
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
encodedPrivateKey: stored.encodedPrivateKey,
|
||||
keyId: stored.keyId,
|
||||
createdAt: stored.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
storeEncodedRecoveryKey(params: {
|
||||
encodedPrivateKey: string;
|
||||
keyId?: string | null;
|
||||
keyInfo?: MatrixStoredRecoveryKey["keyInfo"];
|
||||
}): {
|
||||
encodedPrivateKey?: string;
|
||||
keyId?: string | null;
|
||||
createdAt?: string;
|
||||
} {
|
||||
const encodedPrivateKey = params.encodedPrivateKey.trim();
|
||||
if (!encodedPrivateKey) {
|
||||
throw new Error("Matrix recovery key is required");
|
||||
}
|
||||
let privateKey: Uint8Array;
|
||||
try {
|
||||
privateKey = decodeRecoveryKey(encodedPrivateKey);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Invalid Matrix recovery key: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedKeyId =
|
||||
typeof params.keyId === "string" && params.keyId.trim() ? params.keyId.trim() : null;
|
||||
const keyInfo = params.keyInfo ?? this.loadStoredRecoveryKey()?.keyInfo;
|
||||
this.saveRecoveryKeyToDisk({
|
||||
keyId: normalizedKeyId,
|
||||
keyInfo,
|
||||
privateKey,
|
||||
encodedPrivateKey,
|
||||
});
|
||||
if (normalizedKeyId) {
|
||||
this.rememberSecretStorageKey(normalizedKeyId, privateKey, keyInfo);
|
||||
}
|
||||
return this.getRecoveryKeySummary() ?? {};
|
||||
}
|
||||
|
||||
async bootstrapSecretStorageWithRecoveryKey(
|
||||
crypto: MatrixCryptoBootstrapApi,
|
||||
options: { setupNewKeyBackup?: boolean } = {},
|
||||
): Promise<void> {
|
||||
let status: MatrixSecretStorageStatus | null = null;
|
||||
if (typeof crypto.getSecretStorageStatus === "function") {
|
||||
try {
|
||||
status = await crypto.getSecretStorageStatus();
|
||||
} catch (err) {
|
||||
LogService.warn("MatrixClientLite", "Failed to read secret storage status:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const hasDefaultSecretStorageKey = Boolean(status?.defaultKeyId);
|
||||
const hasKnownInvalidSecrets = Object.values(status?.secretStorageKeyValidityMap ?? {}).some(
|
||||
(valid) => valid === false,
|
||||
);
|
||||
let generatedRecoveryKey = false;
|
||||
const storedRecovery = this.loadStoredRecoveryKey();
|
||||
let recoveryKey: MatrixGeneratedSecretStorageKey | null = storedRecovery
|
||||
? {
|
||||
keyInfo: storedRecovery.keyInfo,
|
||||
privateKey: new Uint8Array(Buffer.from(storedRecovery.privateKeyBase64, "base64")),
|
||||
encodedPrivateKey: storedRecovery.encodedPrivateKey,
|
||||
}
|
||||
: null;
|
||||
|
||||
if (recoveryKey && status?.defaultKeyId) {
|
||||
const defaultKeyId = status.defaultKeyId;
|
||||
this.rememberSecretStorageKey(defaultKeyId, recoveryKey.privateKey, recoveryKey.keyInfo);
|
||||
if (storedRecovery?.keyId !== defaultKeyId) {
|
||||
this.saveRecoveryKeyToDisk({
|
||||
keyId: defaultKeyId,
|
||||
keyInfo: recoveryKey.keyInfo,
|
||||
privateKey: recoveryKey.privateKey,
|
||||
encodedPrivateKey: recoveryKey.encodedPrivateKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const ensureRecoveryKey = async (): Promise<MatrixGeneratedSecretStorageKey> => {
|
||||
if (recoveryKey) {
|
||||
return recoveryKey;
|
||||
}
|
||||
if (typeof crypto.createRecoveryKeyFromPassphrase !== "function") {
|
||||
throw new Error(
|
||||
"Matrix crypto backend does not support recovery key generation (createRecoveryKeyFromPassphrase missing)",
|
||||
);
|
||||
}
|
||||
recoveryKey = await crypto.createRecoveryKeyFromPassphrase();
|
||||
this.saveRecoveryKeyToDisk(recoveryKey);
|
||||
generatedRecoveryKey = true;
|
||||
return recoveryKey;
|
||||
};
|
||||
|
||||
const shouldRecreateSecretStorage =
|
||||
!hasDefaultSecretStorageKey ||
|
||||
(!recoveryKey && status?.ready === false) ||
|
||||
hasKnownInvalidSecrets;
|
||||
|
||||
if (hasKnownInvalidSecrets) {
|
||||
// Existing secret storage keys can't decrypt required secrets. Generate a fresh recovery key.
|
||||
recoveryKey = null;
|
||||
}
|
||||
|
||||
const secretStorageOptions: {
|
||||
createSecretStorageKey?: () => Promise<MatrixGeneratedSecretStorageKey>;
|
||||
setupNewSecretStorage?: boolean;
|
||||
setupNewKeyBackup?: boolean;
|
||||
} = {
|
||||
setupNewKeyBackup: options.setupNewKeyBackup === true,
|
||||
};
|
||||
|
||||
if (shouldRecreateSecretStorage) {
|
||||
secretStorageOptions.setupNewSecretStorage = true;
|
||||
secretStorageOptions.createSecretStorageKey = ensureRecoveryKey;
|
||||
}
|
||||
|
||||
await crypto.bootstrapSecretStorage(secretStorageOptions);
|
||||
|
||||
if (generatedRecoveryKey && this.recoveryKeyPath) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
`Generated Matrix recovery key and saved it to ${this.recoveryKeyPath}. Keep this file secure.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private rememberSecretStorageKey(
|
||||
keyId: string,
|
||||
key: Uint8Array,
|
||||
keyInfo?: MatrixStoredRecoveryKey["keyInfo"],
|
||||
): void {
|
||||
if (!keyId.trim()) {
|
||||
return;
|
||||
}
|
||||
this.secretStorageKeyCache.set(keyId, {
|
||||
key: new Uint8Array(key),
|
||||
keyInfo,
|
||||
});
|
||||
}
|
||||
|
||||
private loadStoredRecoveryKey(): MatrixStoredRecoveryKey | null {
|
||||
if (!this.recoveryKeyPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (!fs.existsSync(this.recoveryKeyPath)) {
|
||||
return null;
|
||||
}
|
||||
const raw = fs.readFileSync(this.recoveryKeyPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Partial<MatrixStoredRecoveryKey>;
|
||||
if (
|
||||
parsed.version !== 1 ||
|
||||
typeof parsed.createdAt !== "string" ||
|
||||
typeof parsed.privateKeyBase64 !== "string" ||
|
||||
!parsed.privateKeyBase64.trim()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
createdAt: parsed.createdAt,
|
||||
keyId: typeof parsed.keyId === "string" ? parsed.keyId : null,
|
||||
encodedPrivateKey:
|
||||
typeof parsed.encodedPrivateKey === "string" ? parsed.encodedPrivateKey : undefined,
|
||||
privateKeyBase64: parsed.privateKeyBase64,
|
||||
keyInfo:
|
||||
parsed.keyInfo && typeof parsed.keyInfo === "object"
|
||||
? {
|
||||
passphrase: parsed.keyInfo.passphrase,
|
||||
name: typeof parsed.keyInfo.name === "string" ? parsed.keyInfo.name : undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private saveRecoveryKeyToDisk(params: MatrixGeneratedSecretStorageKey): void {
|
||||
if (!this.recoveryKeyPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload: MatrixStoredRecoveryKey = {
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
keyId: typeof params.keyId === "string" ? params.keyId : null,
|
||||
encodedPrivateKey: params.encodedPrivateKey,
|
||||
privateKeyBase64: Buffer.from(params.privateKey).toString("base64"),
|
||||
keyInfo: params.keyInfo
|
||||
? {
|
||||
passphrase: params.keyInfo.passphrase,
|
||||
name: params.keyInfo.name,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
fs.mkdirSync(path.dirname(this.recoveryKeyPath), { recursive: true });
|
||||
fs.writeFileSync(this.recoveryKeyPath, JSON.stringify(payload, null, 2), "utf8");
|
||||
fs.chmodSync(this.recoveryKeyPath, 0o600);
|
||||
} catch (err) {
|
||||
LogService.warn("MatrixClientLite", "Failed to persist recovery key:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
171
extensions/matrix-js/src/matrix/sdk/transport.ts
Normal file
171
extensions/matrix-js/src/matrix/sdk/transport.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
|
||||
|
||||
type QueryValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| undefined
|
||||
| Array<string | number | boolean | null | undefined>;
|
||||
|
||||
export type QueryParams = Record<string, QueryValue> | null | undefined;
|
||||
|
||||
function normalizeEndpoint(endpoint: string): string {
|
||||
if (!endpoint) {
|
||||
return "/";
|
||||
}
|
||||
return endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
||||
}
|
||||
|
||||
function applyQuery(url: URL, qs: QueryParams): void {
|
||||
if (!qs) {
|
||||
return;
|
||||
}
|
||||
for (const [key, rawValue] of Object.entries(qs)) {
|
||||
if (rawValue === undefined || rawValue === null) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(rawValue)) {
|
||||
for (const item of rawValue) {
|
||||
if (item === undefined || item === null) {
|
||||
continue;
|
||||
}
|
||||
url.searchParams.append(key, String(item));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
url.searchParams.set(key, String(rawValue));
|
||||
}
|
||||
}
|
||||
|
||||
function isRedirectStatus(statusCode: number): boolean {
|
||||
return statusCode >= 300 && statusCode < 400;
|
||||
}
|
||||
|
||||
async function fetchWithSafeRedirects(url: URL, init: RequestInit): Promise<Response> {
|
||||
let currentUrl = new URL(url.toString());
|
||||
let method = (init.method ?? "GET").toUpperCase();
|
||||
let body = init.body;
|
||||
let headers = new Headers(init.headers ?? {});
|
||||
const maxRedirects = 5;
|
||||
|
||||
for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) {
|
||||
const response = await fetch(currentUrl, {
|
||||
...init,
|
||||
method,
|
||||
body,
|
||||
headers,
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
if (!isRedirectStatus(response.status)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const location = response.headers.get("location");
|
||||
if (!location) {
|
||||
throw new Error(`Matrix redirect missing location header (${currentUrl.toString()})`);
|
||||
}
|
||||
|
||||
const nextUrl = new URL(location, currentUrl);
|
||||
if (nextUrl.protocol !== currentUrl.protocol) {
|
||||
throw new Error(
|
||||
`Blocked cross-protocol redirect (${currentUrl.protocol} -> ${nextUrl.protocol})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (nextUrl.origin !== currentUrl.origin) {
|
||||
headers = new Headers(headers);
|
||||
headers.delete("authorization");
|
||||
}
|
||||
|
||||
if (
|
||||
response.status === 303 ||
|
||||
((response.status === 301 || response.status === 302) &&
|
||||
method !== "GET" &&
|
||||
method !== "HEAD")
|
||||
) {
|
||||
method = "GET";
|
||||
body = undefined;
|
||||
headers = new Headers(headers);
|
||||
headers.delete("content-type");
|
||||
headers.delete("content-length");
|
||||
}
|
||||
|
||||
currentUrl = nextUrl;
|
||||
}
|
||||
|
||||
throw new Error(`Too many redirects while requesting ${url.toString()}`);
|
||||
}
|
||||
|
||||
export async function performMatrixRequest(params: {
|
||||
homeserver: string;
|
||||
accessToken: string;
|
||||
method: HttpMethod;
|
||||
endpoint: string;
|
||||
qs?: QueryParams;
|
||||
body?: unknown;
|
||||
timeoutMs: number;
|
||||
raw?: boolean;
|
||||
allowAbsoluteEndpoint?: boolean;
|
||||
}): Promise<{ response: Response; text: string; buffer: Buffer }> {
|
||||
const isAbsoluteEndpoint =
|
||||
params.endpoint.startsWith("http://") || params.endpoint.startsWith("https://");
|
||||
if (isAbsoluteEndpoint && params.allowAbsoluteEndpoint !== true) {
|
||||
throw new Error(
|
||||
`Absolute Matrix endpoint is blocked by default: ${params.endpoint}. Set allowAbsoluteEndpoint=true to opt in.`,
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = isAbsoluteEndpoint
|
||||
? new URL(params.endpoint)
|
||||
: new URL(normalizeEndpoint(params.endpoint), params.homeserver);
|
||||
applyQuery(baseUrl, params.qs);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Accept", params.raw ? "*/*" : "application/json");
|
||||
if (params.accessToken) {
|
||||
headers.set("Authorization", `Bearer ${params.accessToken}`);
|
||||
}
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
if (params.body !== undefined) {
|
||||
if (
|
||||
params.body instanceof Uint8Array ||
|
||||
params.body instanceof ArrayBuffer ||
|
||||
typeof params.body === "string"
|
||||
) {
|
||||
body = params.body as BodyInit;
|
||||
} else {
|
||||
headers.set("Content-Type", "application/json");
|
||||
body = JSON.stringify(params.body);
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), params.timeoutMs);
|
||||
try {
|
||||
const response = await fetchWithSafeRedirects(baseUrl, {
|
||||
method: params.method,
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (params.raw) {
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
return {
|
||||
response,
|
||||
text: bytes.toString("utf8"),
|
||||
buffer: bytes,
|
||||
};
|
||||
}
|
||||
const text = await response.text();
|
||||
return {
|
||||
response,
|
||||
text,
|
||||
buffer: Buffer.from(text, "utf8"),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
217
extensions/matrix-js/src/matrix/sdk/types.ts
Normal file
217
extensions/matrix-js/src/matrix/sdk/types.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import type { MatrixVerificationRequestLike } from "./verification-manager.js";
|
||||
|
||||
export type MatrixRawEvent = {
|
||||
event_id: string;
|
||||
sender: string;
|
||||
type: string;
|
||||
origin_server_ts: number;
|
||||
content: Record<string, unknown>;
|
||||
unsigned?: {
|
||||
age?: number;
|
||||
redacted_because?: unknown;
|
||||
};
|
||||
state_key?: string;
|
||||
};
|
||||
|
||||
export type MatrixClientEventMap = {
|
||||
"room.event": [roomId: string, event: MatrixRawEvent];
|
||||
"room.message": [roomId: string, event: MatrixRawEvent];
|
||||
"room.encrypted_event": [roomId: string, event: MatrixRawEvent];
|
||||
"room.decrypted_event": [roomId: string, event: MatrixRawEvent];
|
||||
"room.failed_decryption": [roomId: string, event: MatrixRawEvent, error: Error];
|
||||
"room.invite": [roomId: string, event: MatrixRawEvent];
|
||||
"room.join": [roomId: string, event: MatrixRawEvent];
|
||||
};
|
||||
|
||||
export type EncryptedFile = {
|
||||
url: string;
|
||||
key: {
|
||||
kty: string;
|
||||
key_ops: string[];
|
||||
alg: string;
|
||||
k: string;
|
||||
ext: boolean;
|
||||
};
|
||||
iv: string;
|
||||
hashes: Record<string, string>;
|
||||
v: string;
|
||||
};
|
||||
|
||||
export type FileWithThumbnailInfo = {
|
||||
size?: number;
|
||||
mimetype?: string;
|
||||
thumbnail_url?: string;
|
||||
thumbnail_info?: {
|
||||
w?: number;
|
||||
h?: number;
|
||||
mimetype?: string;
|
||||
size?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type DimensionalFileInfo = FileWithThumbnailInfo & {
|
||||
w?: number;
|
||||
h?: number;
|
||||
};
|
||||
|
||||
export type TimedFileInfo = FileWithThumbnailInfo & {
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
export type VideoFileInfo = DimensionalFileInfo &
|
||||
TimedFileInfo & {
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
export type MessageEventContent = {
|
||||
msgtype?: string;
|
||||
body?: string;
|
||||
format?: string;
|
||||
formatted_body?: string;
|
||||
filename?: string;
|
||||
url?: string;
|
||||
file?: EncryptedFile;
|
||||
info?: Record<string, unknown>;
|
||||
"m.relates_to"?: Record<string, unknown>;
|
||||
"m.new_content"?: unknown;
|
||||
"m.mentions"?: {
|
||||
user_ids?: string[];
|
||||
room?: boolean;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type TextualMessageEventContent = MessageEventContent & {
|
||||
msgtype: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type LocationMessageEventContent = MessageEventContent & {
|
||||
msgtype?: string;
|
||||
geo_uri?: string;
|
||||
};
|
||||
|
||||
export type MatrixSecretStorageStatus = {
|
||||
ready: boolean;
|
||||
defaultKeyId: string | null;
|
||||
secretStorageKeyValidityMap?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export type MatrixGeneratedSecretStorageKey = {
|
||||
keyId?: string | null;
|
||||
keyInfo?: {
|
||||
passphrase?: unknown;
|
||||
name?: string;
|
||||
};
|
||||
privateKey: Uint8Array;
|
||||
encodedPrivateKey?: string;
|
||||
};
|
||||
|
||||
export type MatrixDeviceVerificationStatusLike = {
|
||||
isVerified?: () => boolean;
|
||||
localVerified?: boolean;
|
||||
crossSigningVerified?: boolean;
|
||||
signedByOwner?: boolean;
|
||||
};
|
||||
|
||||
export type MatrixKeyBackupInfo = {
|
||||
algorithm: string;
|
||||
auth_data: Record<string, unknown>;
|
||||
count?: number;
|
||||
etag?: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
export type MatrixKeyBackupTrustInfo = {
|
||||
trusted: boolean;
|
||||
matchesDecryptionKey: boolean;
|
||||
};
|
||||
|
||||
export type MatrixRoomKeyBackupRestoreResult = {
|
||||
total: number;
|
||||
imported: number;
|
||||
};
|
||||
|
||||
export type MatrixImportRoomKeyProgress = {
|
||||
stage: string;
|
||||
successes?: number;
|
||||
failures?: number;
|
||||
total?: number;
|
||||
};
|
||||
|
||||
export type MatrixSecretStorageKeyDescription = {
|
||||
passphrase?: unknown;
|
||||
name?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type MatrixCryptoCallbacks = {
|
||||
getSecretStorageKey?: (
|
||||
params: { keys: Record<string, MatrixSecretStorageKeyDescription> },
|
||||
name: string,
|
||||
) => Promise<[string, Uint8Array] | null>;
|
||||
cacheSecretStorageKey?: (
|
||||
keyId: string,
|
||||
keyInfo: MatrixSecretStorageKeyDescription,
|
||||
key: Uint8Array,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type MatrixStoredRecoveryKey = {
|
||||
version: 1;
|
||||
createdAt: string;
|
||||
keyId?: string | null;
|
||||
encodedPrivateKey?: string;
|
||||
privateKeyBase64: string;
|
||||
keyInfo?: {
|
||||
passphrase?: unknown;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MatrixAuthDict = Record<string, unknown>;
|
||||
|
||||
export type MatrixUiAuthCallback = <T>(
|
||||
makeRequest: (authData: MatrixAuthDict | null) => Promise<T>,
|
||||
) => Promise<T>;
|
||||
|
||||
export type MatrixCryptoBootstrapApi = {
|
||||
on: (eventName: string, listener: (...args: unknown[]) => void) => void;
|
||||
bootstrapCrossSigning: (opts: {
|
||||
setupNewCrossSigning?: boolean;
|
||||
authUploadDeviceSigningKeys?: MatrixUiAuthCallback;
|
||||
}) => Promise<void>;
|
||||
bootstrapSecretStorage: (opts?: {
|
||||
createSecretStorageKey?: () => Promise<MatrixGeneratedSecretStorageKey>;
|
||||
setupNewSecretStorage?: boolean;
|
||||
setupNewKeyBackup?: boolean;
|
||||
}) => Promise<void>;
|
||||
createRecoveryKeyFromPassphrase?: (password?: string) => Promise<MatrixGeneratedSecretStorageKey>;
|
||||
getSecretStorageStatus?: () => Promise<MatrixSecretStorageStatus>;
|
||||
requestOwnUserVerification: () => Promise<unknown | null>;
|
||||
requestDeviceVerification?: (
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
) => Promise<MatrixVerificationRequestLike>;
|
||||
requestVerificationDM?: (
|
||||
userId: string,
|
||||
roomId: string,
|
||||
) => Promise<MatrixVerificationRequestLike>;
|
||||
getDeviceVerificationStatus?: (
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
) => Promise<MatrixDeviceVerificationStatusLike | null>;
|
||||
getSessionBackupPrivateKey?: () => Promise<Uint8Array | null>;
|
||||
loadSessionBackupPrivateKeyFromSecretStorage?: () => Promise<void>;
|
||||
getActiveSessionBackupVersion?: () => Promise<string | null>;
|
||||
getKeyBackupInfo?: () => Promise<MatrixKeyBackupInfo | null>;
|
||||
isKeyBackupTrusted?: (info: MatrixKeyBackupInfo) => Promise<MatrixKeyBackupTrustInfo>;
|
||||
checkKeyBackupAndEnable?: () => Promise<unknown>;
|
||||
restoreKeyBackup?: (opts?: {
|
||||
progressCallback?: (progress: MatrixImportRoomKeyProgress) => void;
|
||||
}) => Promise<MatrixRoomKeyBackupRestoreResult>;
|
||||
setDeviceVerified?: (userId: string, deviceId: string, verified?: boolean) => Promise<void>;
|
||||
crossSignDevice?: (deviceId: string) => Promise<void>;
|
||||
isCrossSigningReady?: () => Promise<boolean>;
|
||||
userHasCrossSigningKeys?: (userId?: string, downloadUncached?: boolean) => Promise<boolean>;
|
||||
};
|
||||
347
extensions/matrix-js/src/matrix/sdk/verification-manager.test.ts
Normal file
347
extensions/matrix-js/src/matrix/sdk/verification-manager.test.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import {
|
||||
VerificationPhase,
|
||||
VerificationRequestEvent,
|
||||
} from "matrix-js-sdk/lib/crypto-api/verification.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
MatrixVerificationManager,
|
||||
type MatrixShowQrCodeCallbacks,
|
||||
type MatrixShowSasCallbacks,
|
||||
type MatrixVerificationRequestLike,
|
||||
type MatrixVerifierLike,
|
||||
} from "./verification-manager.js";
|
||||
|
||||
class MockVerifier extends EventEmitter implements MatrixVerifierLike {
|
||||
constructor(
|
||||
private readonly sasCallbacks: MatrixShowSasCallbacks | null,
|
||||
private readonly qrCallbacks: MatrixShowQrCodeCallbacks | null,
|
||||
private readonly verifyImpl: () => Promise<void> = async () => {},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
verify(): Promise<void> {
|
||||
return this.verifyImpl();
|
||||
}
|
||||
|
||||
cancel(_e: Error): void {
|
||||
void _e;
|
||||
}
|
||||
|
||||
getShowSasCallbacks(): MatrixShowSasCallbacks | null {
|
||||
return this.sasCallbacks;
|
||||
}
|
||||
|
||||
getReciprocateQrCodeCallbacks(): MatrixShowQrCodeCallbacks | null {
|
||||
return this.qrCallbacks;
|
||||
}
|
||||
}
|
||||
|
||||
class MockVerificationRequest extends EventEmitter implements MatrixVerificationRequestLike {
|
||||
transactionId?: string;
|
||||
roomId?: string;
|
||||
initiatedByMe = false;
|
||||
otherUserId = "@alice:example.org";
|
||||
otherDeviceId?: string;
|
||||
isSelfVerification = false;
|
||||
phase = VerificationPhase.Requested;
|
||||
pending = true;
|
||||
accepting = false;
|
||||
declining = false;
|
||||
methods: string[] = ["m.sas.v1"];
|
||||
chosenMethod?: string | null;
|
||||
cancellationCode?: string | null;
|
||||
verifier?: MatrixVerifierLike;
|
||||
|
||||
constructor(init?: Partial<MockVerificationRequest>) {
|
||||
super();
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
accept = vi.fn(async () => {
|
||||
this.phase = VerificationPhase.Ready;
|
||||
});
|
||||
|
||||
cancel = vi.fn(async () => {
|
||||
this.phase = VerificationPhase.Cancelled;
|
||||
});
|
||||
|
||||
startVerification = vi.fn(async (_method: string) => {
|
||||
if (!this.verifier) {
|
||||
throw new Error("verifier not configured");
|
||||
}
|
||||
this.phase = VerificationPhase.Started;
|
||||
return this.verifier;
|
||||
});
|
||||
|
||||
scanQRCode = vi.fn(async (_qrCodeData: Uint8ClampedArray) => {
|
||||
if (!this.verifier) {
|
||||
throw new Error("verifier not configured");
|
||||
}
|
||||
this.phase = VerificationPhase.Started;
|
||||
return this.verifier;
|
||||
});
|
||||
|
||||
generateQRCode = vi.fn(async () => new Uint8ClampedArray([1, 2, 3]));
|
||||
}
|
||||
|
||||
describe("MatrixVerificationManager", () => {
|
||||
it("handles rust verification requests whose methods getter throws", () => {
|
||||
const manager = new MatrixVerificationManager();
|
||||
const request = new MockVerificationRequest({
|
||||
transactionId: "txn-rust-methods",
|
||||
phase: VerificationPhase.Requested,
|
||||
});
|
||||
Object.defineProperty(request, "methods", {
|
||||
get() {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
});
|
||||
|
||||
const summary = manager.trackVerificationRequest(request);
|
||||
|
||||
expect(summary.id).toBeTruthy();
|
||||
expect(summary.methods).toEqual([]);
|
||||
expect(summary.phase).toBe(VerificationPhase.Requested);
|
||||
});
|
||||
|
||||
it("reuses the same tracked id for repeated transaction IDs", () => {
|
||||
const manager = new MatrixVerificationManager();
|
||||
const first = new MockVerificationRequest({
|
||||
transactionId: "txn-1",
|
||||
phase: VerificationPhase.Requested,
|
||||
});
|
||||
const second = new MockVerificationRequest({
|
||||
transactionId: "txn-1",
|
||||
phase: VerificationPhase.Ready,
|
||||
pending: false,
|
||||
chosenMethod: "m.sas.v1",
|
||||
});
|
||||
|
||||
const firstSummary = manager.trackVerificationRequest(first);
|
||||
const secondSummary = manager.trackVerificationRequest(second);
|
||||
|
||||
expect(secondSummary.id).toBe(firstSummary.id);
|
||||
expect(secondSummary.phase).toBe(VerificationPhase.Ready);
|
||||
expect(secondSummary.pending).toBe(false);
|
||||
expect(secondSummary.chosenMethod).toBe("m.sas.v1");
|
||||
});
|
||||
|
||||
it("starts SAS verification and exposes SAS payload/callback flow", async () => {
|
||||
const confirm = vi.fn(async () => {});
|
||||
const mismatch = vi.fn();
|
||||
const verifier = new MockVerifier(
|
||||
{
|
||||
sas: {
|
||||
decimal: [111, 222, 333],
|
||||
emoji: [
|
||||
["cat", "cat"],
|
||||
["dog", "dog"],
|
||||
["fox", "fox"],
|
||||
],
|
||||
},
|
||||
confirm,
|
||||
mismatch,
|
||||
cancel: vi.fn(),
|
||||
},
|
||||
null,
|
||||
async () => {},
|
||||
);
|
||||
const request = new MockVerificationRequest({
|
||||
transactionId: "txn-2",
|
||||
verifier,
|
||||
});
|
||||
const manager = new MatrixVerificationManager();
|
||||
const tracked = manager.trackVerificationRequest(request);
|
||||
|
||||
const started = await manager.startVerification(tracked.id, "sas");
|
||||
expect(started.hasSas).toBe(true);
|
||||
expect(started.sas?.decimal).toEqual([111, 222, 333]);
|
||||
expect(started.sas?.emoji?.length).toBe(3);
|
||||
|
||||
const sas = manager.getVerificationSas(tracked.id);
|
||||
expect(sas.decimal).toEqual([111, 222, 333]);
|
||||
expect(sas.emoji?.length).toBe(3);
|
||||
|
||||
await manager.confirmVerificationSas(tracked.id);
|
||||
expect(confirm).toHaveBeenCalledTimes(2);
|
||||
|
||||
manager.mismatchVerificationSas(tracked.id);
|
||||
expect(mismatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("auto-starts an incoming verifier exposed via request change events", async () => {
|
||||
const verify = vi.fn(async () => {});
|
||||
const verifier = new MockVerifier(
|
||||
{
|
||||
sas: {
|
||||
decimal: [6158, 1986, 3513],
|
||||
emoji: [
|
||||
["gift", "Gift"],
|
||||
["globe", "Globe"],
|
||||
["horse", "Horse"],
|
||||
],
|
||||
},
|
||||
confirm: vi.fn(async () => {}),
|
||||
mismatch: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
},
|
||||
null,
|
||||
verify,
|
||||
);
|
||||
const request = new MockVerificationRequest({
|
||||
transactionId: "txn-incoming-change",
|
||||
verifier: undefined,
|
||||
});
|
||||
const manager = new MatrixVerificationManager();
|
||||
const tracked = manager.trackVerificationRequest(request);
|
||||
|
||||
request.verifier = verifier;
|
||||
request.emit(VerificationRequestEvent.Change);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(verify).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const summary = manager.listVerifications().find((item) => item.id === tracked.id);
|
||||
expect(summary?.hasSas).toBe(true);
|
||||
expect(summary?.sas?.decimal).toEqual([6158, 1986, 3513]);
|
||||
expect(manager.getVerificationSas(tracked.id).decimal).toEqual([6158, 1986, 3513]);
|
||||
});
|
||||
|
||||
it("auto-starts inbound SAS when request becomes ready without a verifier", async () => {
|
||||
const verify = vi.fn(async () => {});
|
||||
const verifier = new MockVerifier(
|
||||
{
|
||||
sas: {
|
||||
decimal: [1234, 5678, 9012],
|
||||
emoji: [
|
||||
["gift", "Gift"],
|
||||
["rocket", "Rocket"],
|
||||
["butterfly", "Butterfly"],
|
||||
],
|
||||
},
|
||||
confirm: vi.fn(async () => {}),
|
||||
mismatch: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
},
|
||||
null,
|
||||
verify,
|
||||
);
|
||||
const request = new MockVerificationRequest({
|
||||
transactionId: "txn-auto-start-sas",
|
||||
initiatedByMe: false,
|
||||
verifier: undefined,
|
||||
});
|
||||
request.startVerification = vi.fn(async (_method: string) => {
|
||||
request.phase = VerificationPhase.Started;
|
||||
request.verifier = verifier;
|
||||
return verifier;
|
||||
});
|
||||
const manager = new MatrixVerificationManager();
|
||||
const tracked = manager.trackVerificationRequest(request);
|
||||
|
||||
request.phase = VerificationPhase.Ready;
|
||||
request.emit(VerificationRequestEvent.Change);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(request.startVerification).toHaveBeenCalledWith("m.sas.v1");
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(verify).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const summary = manager.listVerifications().find((item) => item.id === tracked.id);
|
||||
expect(summary?.hasSas).toBe(true);
|
||||
expect(summary?.sas?.decimal).toEqual([1234, 5678, 9012]);
|
||||
expect(manager.getVerificationSas(tracked.id).decimal).toEqual([1234, 5678, 9012]);
|
||||
});
|
||||
|
||||
it("auto-confirms inbound SAS when callbacks are available", async () => {
|
||||
const confirm = vi.fn(async () => {});
|
||||
const verifier = new MockVerifier(
|
||||
{
|
||||
sas: {
|
||||
decimal: [6158, 1986, 3513],
|
||||
emoji: [
|
||||
["gift", "Gift"],
|
||||
["globe", "Globe"],
|
||||
["horse", "Horse"],
|
||||
],
|
||||
},
|
||||
confirm,
|
||||
mismatch: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
},
|
||||
null,
|
||||
async () => {},
|
||||
);
|
||||
const request = new MockVerificationRequest({
|
||||
transactionId: "txn-auto-confirm",
|
||||
initiatedByMe: false,
|
||||
verifier,
|
||||
});
|
||||
const manager = new MatrixVerificationManager();
|
||||
manager.trackVerificationRequest(request);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not auto-confirm SAS for verifications initiated by this device", async () => {
|
||||
vi.useFakeTimers();
|
||||
const confirm = vi.fn(async () => {});
|
||||
const verifier = new MockVerifier(
|
||||
{
|
||||
sas: {
|
||||
decimal: [111, 222, 333],
|
||||
emoji: [
|
||||
["cat", "Cat"],
|
||||
["dog", "Dog"],
|
||||
["fox", "Fox"],
|
||||
],
|
||||
},
|
||||
confirm,
|
||||
mismatch: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
},
|
||||
null,
|
||||
async () => {},
|
||||
);
|
||||
const request = new MockVerificationRequest({
|
||||
transactionId: "txn-no-auto-confirm",
|
||||
initiatedByMe: true,
|
||||
verifier,
|
||||
});
|
||||
try {
|
||||
const manager = new MatrixVerificationManager();
|
||||
manager.trackVerificationRequest(request);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("prunes stale terminal sessions during list operations", () => {
|
||||
const now = new Date("2026-02-08T15:00:00.000Z").getTime();
|
||||
const nowSpy = vi.spyOn(Date, "now");
|
||||
nowSpy.mockReturnValue(now);
|
||||
|
||||
const manager = new MatrixVerificationManager();
|
||||
manager.trackVerificationRequest(
|
||||
new MockVerificationRequest({
|
||||
transactionId: "txn-old-done",
|
||||
phase: VerificationPhase.Done,
|
||||
pending: false,
|
||||
}),
|
||||
);
|
||||
|
||||
nowSpy.mockReturnValue(now + 24 * 60 * 60 * 1000 + 1);
|
||||
const summaries = manager.listVerifications();
|
||||
|
||||
expect(summaries).toHaveLength(0);
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
586
extensions/matrix-js/src/matrix/sdk/verification-manager.ts
Normal file
586
extensions/matrix-js/src/matrix/sdk/verification-manager.ts
Normal file
@@ -0,0 +1,586 @@
|
||||
import {
|
||||
VerificationPhase,
|
||||
VerificationRequestEvent,
|
||||
VerifierEvent,
|
||||
} from "matrix-js-sdk/lib/crypto-api/verification.js";
|
||||
import { VerificationMethod } from "matrix-js-sdk/lib/types.js";
|
||||
|
||||
export type MatrixVerificationMethod = "sas" | "show-qr" | "scan-qr";
|
||||
|
||||
export type MatrixVerificationSummary = {
|
||||
id: string;
|
||||
transactionId?: string;
|
||||
roomId?: string;
|
||||
otherUserId: string;
|
||||
otherDeviceId?: string;
|
||||
isSelfVerification: boolean;
|
||||
initiatedByMe: boolean;
|
||||
phase: number;
|
||||
phaseName: string;
|
||||
pending: boolean;
|
||||
methods: string[];
|
||||
chosenMethod?: string | null;
|
||||
canAccept: boolean;
|
||||
hasSas: boolean;
|
||||
sas?: {
|
||||
decimal?: [number, number, number];
|
||||
emoji?: Array<[string, string]>;
|
||||
};
|
||||
hasReciprocateQr: boolean;
|
||||
completed: boolean;
|
||||
error?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type MatrixShowSasCallbacks = {
|
||||
sas: {
|
||||
decimal?: [number, number, number];
|
||||
emoji?: Array<[string, string]>;
|
||||
};
|
||||
confirm: () => Promise<void>;
|
||||
mismatch: () => void;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
export type MatrixShowQrCodeCallbacks = {
|
||||
confirm: () => void;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
export type MatrixVerifierLike = {
|
||||
verify: () => Promise<void>;
|
||||
cancel: (e: Error) => void;
|
||||
getShowSasCallbacks: () => MatrixShowSasCallbacks | null;
|
||||
getReciprocateQrCodeCallbacks: () => MatrixShowQrCodeCallbacks | null;
|
||||
on: (eventName: string, listener: (...args: unknown[]) => void) => void;
|
||||
};
|
||||
|
||||
export type MatrixVerificationRequestLike = {
|
||||
transactionId?: string;
|
||||
roomId?: string;
|
||||
initiatedByMe: boolean;
|
||||
otherUserId: string;
|
||||
otherDeviceId?: string;
|
||||
isSelfVerification: boolean;
|
||||
phase: number;
|
||||
pending: boolean;
|
||||
accepting: boolean;
|
||||
declining: boolean;
|
||||
methods: string[];
|
||||
chosenMethod?: string | null;
|
||||
cancellationCode?: string | null;
|
||||
accept: () => Promise<void>;
|
||||
cancel: (params?: { reason?: string; code?: string }) => Promise<void>;
|
||||
startVerification: (method: string) => Promise<MatrixVerifierLike>;
|
||||
scanQRCode: (qrCodeData: Uint8ClampedArray) => Promise<MatrixVerifierLike>;
|
||||
generateQRCode: () => Promise<Uint8ClampedArray | undefined>;
|
||||
verifier?: MatrixVerifierLike;
|
||||
on: (eventName: string, listener: (...args: unknown[]) => void) => void;
|
||||
};
|
||||
|
||||
export type MatrixVerificationCryptoApi = {
|
||||
requestOwnUserVerification: () => Promise<unknown | null>;
|
||||
requestDeviceVerification?: (
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
) => Promise<MatrixVerificationRequestLike>;
|
||||
requestVerificationDM?: (
|
||||
userId: string,
|
||||
roomId: string,
|
||||
) => Promise<MatrixVerificationRequestLike>;
|
||||
};
|
||||
|
||||
type MatrixVerificationSession = {
|
||||
id: string;
|
||||
request: MatrixVerificationRequestLike;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
error?: string;
|
||||
activeVerifier?: MatrixVerifierLike;
|
||||
verifyPromise?: Promise<void>;
|
||||
verifyStarted: boolean;
|
||||
startRequested: boolean;
|
||||
sasAutoConfirmStarted: boolean;
|
||||
sasCallbacks?: MatrixShowSasCallbacks;
|
||||
reciprocateQrCallbacks?: MatrixShowQrCodeCallbacks;
|
||||
};
|
||||
|
||||
const MAX_TRACKED_VERIFICATION_SESSIONS = 256;
|
||||
const TERMINAL_SESSION_RETENTION_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export class MatrixVerificationManager {
|
||||
private readonly verificationSessions = new Map<string, MatrixVerificationSession>();
|
||||
private verificationSessionCounter = 0;
|
||||
private readonly trackedVerificationRequests = new WeakSet<object>();
|
||||
private readonly trackedVerificationVerifiers = new WeakSet<object>();
|
||||
|
||||
private readRequestValue<T>(
|
||||
request: MatrixVerificationRequestLike,
|
||||
reader: () => T,
|
||||
fallback: T,
|
||||
): T {
|
||||
try {
|
||||
return reader();
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private pruneVerificationSessions(nowMs: number): void {
|
||||
for (const [id, session] of this.verificationSessions) {
|
||||
const phase = this.readRequestValue(session.request, () => session.request.phase, -1);
|
||||
const isTerminal = phase === VerificationPhase.Done || phase === VerificationPhase.Cancelled;
|
||||
if (isTerminal && nowMs - session.updatedAtMs > TERMINAL_SESSION_RETENTION_MS) {
|
||||
this.verificationSessions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.verificationSessions.size <= MAX_TRACKED_VERIFICATION_SESSIONS) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedByAge = Array.from(this.verificationSessions.entries()).sort(
|
||||
(a, b) => a[1].updatedAtMs - b[1].updatedAtMs,
|
||||
);
|
||||
const overflow = this.verificationSessions.size - MAX_TRACKED_VERIFICATION_SESSIONS;
|
||||
for (let i = 0; i < overflow; i += 1) {
|
||||
const entry = sortedByAge[i];
|
||||
if (entry) {
|
||||
this.verificationSessions.delete(entry[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getVerificationPhaseName(phase: number): string {
|
||||
switch (phase) {
|
||||
case VerificationPhase.Unsent:
|
||||
return "unsent";
|
||||
case VerificationPhase.Requested:
|
||||
return "requested";
|
||||
case VerificationPhase.Ready:
|
||||
return "ready";
|
||||
case VerificationPhase.Started:
|
||||
return "started";
|
||||
case VerificationPhase.Cancelled:
|
||||
return "cancelled";
|
||||
case VerificationPhase.Done:
|
||||
return "done";
|
||||
default:
|
||||
return `unknown(${phase})`;
|
||||
}
|
||||
}
|
||||
|
||||
private touchVerificationSession(session: MatrixVerificationSession): void {
|
||||
session.updatedAtMs = Date.now();
|
||||
}
|
||||
|
||||
private buildVerificationSummary(session: MatrixVerificationSession): MatrixVerificationSummary {
|
||||
const request = session.request;
|
||||
const phase = this.readRequestValue(request, () => request.phase, VerificationPhase.Requested);
|
||||
const accepting = this.readRequestValue(request, () => request.accepting, false);
|
||||
const declining = this.readRequestValue(request, () => request.declining, false);
|
||||
const pending = this.readRequestValue(request, () => request.pending, false);
|
||||
const methodsRaw = this.readRequestValue<unknown>(request, () => request.methods, []);
|
||||
const methods = Array.isArray(methodsRaw)
|
||||
? methodsRaw.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
const sasCallbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks();
|
||||
if (sasCallbacks) {
|
||||
session.sasCallbacks = sasCallbacks;
|
||||
}
|
||||
const canAccept = phase < VerificationPhase.Ready && !accepting && !declining;
|
||||
return {
|
||||
id: session.id,
|
||||
transactionId: this.readRequestValue(request, () => request.transactionId, undefined),
|
||||
roomId: this.readRequestValue(request, () => request.roomId, undefined),
|
||||
otherUserId: this.readRequestValue(request, () => request.otherUserId, "unknown"),
|
||||
otherDeviceId: this.readRequestValue(request, () => request.otherDeviceId, undefined),
|
||||
isSelfVerification: this.readRequestValue(request, () => request.isSelfVerification, false),
|
||||
initiatedByMe: this.readRequestValue(request, () => request.initiatedByMe, false),
|
||||
phase,
|
||||
phaseName: this.getVerificationPhaseName(phase),
|
||||
pending,
|
||||
methods,
|
||||
chosenMethod: this.readRequestValue(request, () => request.chosenMethod ?? null, null),
|
||||
canAccept,
|
||||
hasSas: Boolean(sasCallbacks),
|
||||
sas: sasCallbacks
|
||||
? {
|
||||
decimal: sasCallbacks.sas.decimal,
|
||||
emoji: sasCallbacks.sas.emoji,
|
||||
}
|
||||
: undefined,
|
||||
hasReciprocateQr: Boolean(session.reciprocateQrCallbacks),
|
||||
completed: phase === VerificationPhase.Done,
|
||||
error: session.error,
|
||||
createdAt: new Date(session.createdAtMs).toISOString(),
|
||||
updatedAt: new Date(session.updatedAtMs).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private findVerificationSession(id: string): MatrixVerificationSession {
|
||||
const direct = this.verificationSessions.get(id);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
for (const session of this.verificationSessions.values()) {
|
||||
const txId = this.readRequestValue(session.request, () => session.request.transactionId, "");
|
||||
if (txId === id) {
|
||||
return session;
|
||||
}
|
||||
}
|
||||
throw new Error(`Matrix verification request not found: ${id}`);
|
||||
}
|
||||
|
||||
private ensureVerificationRequestTracked(session: MatrixVerificationSession): void {
|
||||
const requestObj = session.request as unknown as object;
|
||||
if (this.trackedVerificationRequests.has(requestObj)) {
|
||||
return;
|
||||
}
|
||||
this.trackedVerificationRequests.add(requestObj);
|
||||
session.request.on(VerificationRequestEvent.Change, () => {
|
||||
this.touchVerificationSession(session);
|
||||
const verifier = this.readRequestValue(session.request, () => session.request.verifier, null);
|
||||
if (verifier) {
|
||||
this.attachVerifierToVerificationSession(session, verifier);
|
||||
}
|
||||
this.maybeAutoStartInboundSas(session);
|
||||
});
|
||||
}
|
||||
|
||||
private maybeAutoStartInboundSas(session: MatrixVerificationSession): void {
|
||||
if (session.activeVerifier || session.verifyStarted || session.startRequested) {
|
||||
return;
|
||||
}
|
||||
if (this.readRequestValue(session.request, () => session.request.initiatedByMe, true)) {
|
||||
return;
|
||||
}
|
||||
const phase = this.readRequestValue(
|
||||
session.request,
|
||||
() => session.request.phase,
|
||||
VerificationPhase.Requested,
|
||||
);
|
||||
if (phase < VerificationPhase.Ready || phase >= VerificationPhase.Cancelled) {
|
||||
return;
|
||||
}
|
||||
const methodsRaw = this.readRequestValue<unknown>(
|
||||
session.request,
|
||||
() => session.request.methods,
|
||||
[],
|
||||
);
|
||||
const methods = Array.isArray(methodsRaw)
|
||||
? methodsRaw.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
const chosenMethod = this.readRequestValue(
|
||||
session.request,
|
||||
() => session.request.chosenMethod,
|
||||
null,
|
||||
);
|
||||
const supportsSas =
|
||||
methods.includes(VerificationMethod.Sas) || chosenMethod === VerificationMethod.Sas;
|
||||
if (!supportsSas) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.startRequested = true;
|
||||
void session.request
|
||||
.startVerification(VerificationMethod.Sas)
|
||||
.then((verifier) => {
|
||||
this.attachVerifierToVerificationSession(session, verifier);
|
||||
this.touchVerificationSession(session);
|
||||
})
|
||||
.catch(() => {
|
||||
session.startRequested = false;
|
||||
});
|
||||
}
|
||||
|
||||
private attachVerifierToVerificationSession(
|
||||
session: MatrixVerificationSession,
|
||||
verifier: MatrixVerifierLike,
|
||||
): void {
|
||||
session.activeVerifier = verifier;
|
||||
this.touchVerificationSession(session);
|
||||
|
||||
const maybeSas = verifier.getShowSasCallbacks();
|
||||
if (maybeSas) {
|
||||
session.sasCallbacks = maybeSas;
|
||||
this.maybeAutoConfirmSas(session);
|
||||
}
|
||||
const maybeReciprocateQr = verifier.getReciprocateQrCodeCallbacks();
|
||||
if (maybeReciprocateQr) {
|
||||
session.reciprocateQrCallbacks = maybeReciprocateQr;
|
||||
}
|
||||
|
||||
const verifierObj = verifier as unknown as object;
|
||||
if (this.trackedVerificationVerifiers.has(verifierObj)) {
|
||||
this.ensureVerificationStarted(session);
|
||||
return;
|
||||
}
|
||||
this.trackedVerificationVerifiers.add(verifierObj);
|
||||
|
||||
verifier.on(VerifierEvent.ShowSas, (sas) => {
|
||||
session.sasCallbacks = sas as MatrixShowSasCallbacks;
|
||||
this.touchVerificationSession(session);
|
||||
this.maybeAutoConfirmSas(session);
|
||||
});
|
||||
verifier.on(VerifierEvent.ShowReciprocateQr, (qr) => {
|
||||
session.reciprocateQrCallbacks = qr as MatrixShowQrCodeCallbacks;
|
||||
this.touchVerificationSession(session);
|
||||
});
|
||||
verifier.on(VerifierEvent.Cancel, (err) => {
|
||||
session.error = err instanceof Error ? err.message : String(err);
|
||||
this.touchVerificationSession(session);
|
||||
});
|
||||
this.ensureVerificationStarted(session);
|
||||
}
|
||||
|
||||
private maybeAutoConfirmSas(session: MatrixVerificationSession): void {
|
||||
if (session.sasAutoConfirmStarted) {
|
||||
return;
|
||||
}
|
||||
if (this.readRequestValue(session.request, () => session.request.initiatedByMe, true)) {
|
||||
return;
|
||||
}
|
||||
const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks();
|
||||
if (!callbacks) {
|
||||
return;
|
||||
}
|
||||
session.sasCallbacks = callbacks;
|
||||
session.sasAutoConfirmStarted = true;
|
||||
void callbacks
|
||||
.confirm()
|
||||
.then(() => {
|
||||
this.touchVerificationSession(session);
|
||||
})
|
||||
.catch((err) => {
|
||||
session.error = err instanceof Error ? err.message : String(err);
|
||||
this.touchVerificationSession(session);
|
||||
});
|
||||
}
|
||||
|
||||
private ensureVerificationStarted(session: MatrixVerificationSession): void {
|
||||
if (!session.activeVerifier || session.verifyStarted) {
|
||||
return;
|
||||
}
|
||||
session.verifyStarted = true;
|
||||
const verifier = session.activeVerifier;
|
||||
session.verifyPromise = verifier
|
||||
.verify()
|
||||
.then(() => {
|
||||
this.touchVerificationSession(session);
|
||||
})
|
||||
.catch((err) => {
|
||||
session.error = err instanceof Error ? err.message : String(err);
|
||||
this.touchVerificationSession(session);
|
||||
});
|
||||
}
|
||||
|
||||
trackVerificationRequest(request: MatrixVerificationRequestLike): MatrixVerificationSummary {
|
||||
this.pruneVerificationSessions(Date.now());
|
||||
const txId = this.readRequestValue(request, () => request.transactionId?.trim(), "");
|
||||
if (txId) {
|
||||
for (const existing of this.verificationSessions.values()) {
|
||||
const existingTxId = this.readRequestValue(
|
||||
existing.request,
|
||||
() => existing.request.transactionId,
|
||||
"",
|
||||
);
|
||||
if (existingTxId === txId) {
|
||||
existing.request = request;
|
||||
this.ensureVerificationRequestTracked(existing);
|
||||
const verifier = this.readRequestValue(request, () => request.verifier, null);
|
||||
if (verifier) {
|
||||
this.attachVerifierToVerificationSession(existing, verifier);
|
||||
}
|
||||
this.touchVerificationSession(existing);
|
||||
return this.buildVerificationSummary(existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const id = `verification-${++this.verificationSessionCounter}`;
|
||||
const session: MatrixVerificationSession = {
|
||||
id,
|
||||
request,
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
verifyStarted: false,
|
||||
startRequested: false,
|
||||
sasAutoConfirmStarted: false,
|
||||
};
|
||||
this.verificationSessions.set(session.id, session);
|
||||
this.ensureVerificationRequestTracked(session);
|
||||
const verifier = this.readRequestValue(request, () => request.verifier, null);
|
||||
if (verifier) {
|
||||
this.attachVerifierToVerificationSession(session, verifier);
|
||||
}
|
||||
this.maybeAutoStartInboundSas(session);
|
||||
return this.buildVerificationSummary(session);
|
||||
}
|
||||
|
||||
async requestOwnUserVerification(
|
||||
crypto: MatrixVerificationCryptoApi | undefined,
|
||||
): Promise<MatrixVerificationSummary | null> {
|
||||
if (!crypto) {
|
||||
return null;
|
||||
}
|
||||
const request =
|
||||
(await crypto.requestOwnUserVerification()) as MatrixVerificationRequestLike | null;
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
return this.trackVerificationRequest(request);
|
||||
}
|
||||
|
||||
listVerifications(): MatrixVerificationSummary[] {
|
||||
this.pruneVerificationSessions(Date.now());
|
||||
const summaries = Array.from(this.verificationSessions.values()).map((session) =>
|
||||
this.buildVerificationSummary(session),
|
||||
);
|
||||
return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
async requestVerification(
|
||||
crypto: MatrixVerificationCryptoApi | undefined,
|
||||
params: {
|
||||
ownUser?: boolean;
|
||||
userId?: string;
|
||||
deviceId?: string;
|
||||
roomId?: string;
|
||||
},
|
||||
): Promise<MatrixVerificationSummary> {
|
||||
if (!crypto) {
|
||||
throw new Error("Matrix crypto is not available");
|
||||
}
|
||||
let request: MatrixVerificationRequestLike | null = null;
|
||||
if (params.ownUser) {
|
||||
request = (await crypto.requestOwnUserVerification()) as MatrixVerificationRequestLike | null;
|
||||
} else if (params.userId && params.deviceId && crypto.requestDeviceVerification) {
|
||||
request = await crypto.requestDeviceVerification(params.userId, params.deviceId);
|
||||
} else if (params.userId && params.roomId && crypto.requestVerificationDM) {
|
||||
request = await crypto.requestVerificationDM(params.userId, params.roomId);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Matrix verification request requires one of: ownUser, userId+deviceId, or userId+roomId",
|
||||
);
|
||||
}
|
||||
|
||||
if (!request) {
|
||||
throw new Error("Matrix verification request could not be created");
|
||||
}
|
||||
return this.trackVerificationRequest(request);
|
||||
}
|
||||
|
||||
async acceptVerification(id: string): Promise<MatrixVerificationSummary> {
|
||||
const session = this.findVerificationSession(id);
|
||||
await session.request.accept();
|
||||
this.touchVerificationSession(session);
|
||||
return this.buildVerificationSummary(session);
|
||||
}
|
||||
|
||||
async cancelVerification(
|
||||
id: string,
|
||||
params?: { reason?: string; code?: string },
|
||||
): Promise<MatrixVerificationSummary> {
|
||||
const session = this.findVerificationSession(id);
|
||||
await session.request.cancel(params);
|
||||
this.touchVerificationSession(session);
|
||||
return this.buildVerificationSummary(session);
|
||||
}
|
||||
|
||||
async startVerification(
|
||||
id: string,
|
||||
method: MatrixVerificationMethod = "sas",
|
||||
): Promise<MatrixVerificationSummary> {
|
||||
const session = this.findVerificationSession(id);
|
||||
if (method !== "sas") {
|
||||
throw new Error("Matrix startVerification currently supports only SAS directly");
|
||||
}
|
||||
const verifier = await session.request.startVerification(VerificationMethod.Sas);
|
||||
this.attachVerifierToVerificationSession(session, verifier);
|
||||
this.ensureVerificationStarted(session);
|
||||
return this.buildVerificationSummary(session);
|
||||
}
|
||||
|
||||
async generateVerificationQr(id: string): Promise<{ qrDataBase64: string }> {
|
||||
const session = this.findVerificationSession(id);
|
||||
const qr = await session.request.generateQRCode();
|
||||
if (!qr) {
|
||||
throw new Error("Matrix verification QR data is not available yet");
|
||||
}
|
||||
return { qrDataBase64: Buffer.from(qr).toString("base64") };
|
||||
}
|
||||
|
||||
async scanVerificationQr(id: string, qrDataBase64: string): Promise<MatrixVerificationSummary> {
|
||||
const session = this.findVerificationSession(id);
|
||||
const trimmed = qrDataBase64.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Matrix verification QR payload is required");
|
||||
}
|
||||
const qrBytes = Buffer.from(trimmed, "base64");
|
||||
if (qrBytes.length === 0) {
|
||||
throw new Error("Matrix verification QR payload is invalid base64");
|
||||
}
|
||||
const verifier = await session.request.scanQRCode(new Uint8ClampedArray(qrBytes));
|
||||
this.attachVerifierToVerificationSession(session, verifier);
|
||||
this.ensureVerificationStarted(session);
|
||||
return this.buildVerificationSummary(session);
|
||||
}
|
||||
|
||||
async confirmVerificationSas(id: string): Promise<MatrixVerificationSummary> {
|
||||
const session = this.findVerificationSession(id);
|
||||
const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks();
|
||||
if (!callbacks) {
|
||||
throw new Error("Matrix SAS confirmation is not available for this verification request");
|
||||
}
|
||||
session.sasCallbacks = callbacks;
|
||||
await callbacks.confirm();
|
||||
this.touchVerificationSession(session);
|
||||
return this.buildVerificationSummary(session);
|
||||
}
|
||||
|
||||
mismatchVerificationSas(id: string): MatrixVerificationSummary {
|
||||
const session = this.findVerificationSession(id);
|
||||
const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks();
|
||||
if (!callbacks) {
|
||||
throw new Error("Matrix SAS mismatch is not available for this verification request");
|
||||
}
|
||||
session.sasCallbacks = callbacks;
|
||||
callbacks.mismatch();
|
||||
this.touchVerificationSession(session);
|
||||
return this.buildVerificationSummary(session);
|
||||
}
|
||||
|
||||
confirmVerificationReciprocateQr(id: string): MatrixVerificationSummary {
|
||||
const session = this.findVerificationSession(id);
|
||||
const callbacks =
|
||||
session.reciprocateQrCallbacks ?? session.activeVerifier?.getReciprocateQrCodeCallbacks();
|
||||
if (!callbacks) {
|
||||
throw new Error(
|
||||
"Matrix reciprocate-QR confirmation is not available for this verification request",
|
||||
);
|
||||
}
|
||||
session.reciprocateQrCallbacks = callbacks;
|
||||
callbacks.confirm();
|
||||
this.touchVerificationSession(session);
|
||||
return this.buildVerificationSummary(session);
|
||||
}
|
||||
|
||||
getVerificationSas(id: string): {
|
||||
decimal?: [number, number, number];
|
||||
emoji?: Array<[string, string]>;
|
||||
} {
|
||||
const session = this.findVerificationSession(id);
|
||||
const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks();
|
||||
if (!callbacks) {
|
||||
throw new Error("Matrix SAS data is not available for this verification request");
|
||||
}
|
||||
session.sasCallbacks = callbacks;
|
||||
return {
|
||||
decimal: callbacks.sas.decimal,
|
||||
emoji: callbacks.sas.emoji,
|
||||
};
|
||||
}
|
||||
}
|
||||
228
extensions/matrix-js/src/matrix/send.test.ts
Normal file
228
extensions/matrix-js/src/matrix/send.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix-js";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setMatrixRuntime } from "../runtime.js";
|
||||
|
||||
const loadWebMediaMock = vi.fn().mockResolvedValue({
|
||||
buffer: Buffer.from("media"),
|
||||
fileName: "photo.png",
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
});
|
||||
const getImageMetadataMock = vi.fn().mockResolvedValue(null);
|
||||
const resizeToJpegMock = vi.fn();
|
||||
|
||||
const runtimeStub = {
|
||||
config: {
|
||||
loadConfig: () => ({}),
|
||||
},
|
||||
media: {
|
||||
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
|
||||
mediaKindFromMime: () => "image",
|
||||
isVoiceCompatibleAudio: () => false,
|
||||
getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args),
|
||||
resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args),
|
||||
},
|
||||
channel: {
|
||||
text: {
|
||||
resolveTextChunkLimit: () => 4000,
|
||||
resolveChunkMode: () => "length",
|
||||
chunkMarkdownText: (text: string) => (text ? [text] : []),
|
||||
chunkMarkdownTextWithMode: (text: string) => (text ? [text] : []),
|
||||
resolveMarkdownTableMode: () => "code",
|
||||
convertMarkdownTables: (text: string) => text,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
|
||||
|
||||
const makeClient = () => {
|
||||
const sendMessage = vi.fn().mockResolvedValue("evt1");
|
||||
const uploadContent = vi.fn().mockResolvedValue("mxc://example/file");
|
||||
const client = {
|
||||
sendMessage,
|
||||
uploadContent,
|
||||
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
|
||||
} as unknown as import("./sdk.js").MatrixClient;
|
||||
return { client, sendMessage, uploadContent };
|
||||
};
|
||||
|
||||
describe("sendMessageMatrix media", () => {
|
||||
beforeAll(async () => {
|
||||
setMatrixRuntime(runtimeStub);
|
||||
({ sendMessageMatrix } = await import("./send.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
loadWebMediaMock.mockReset().mockResolvedValue({
|
||||
buffer: Buffer.from("media"),
|
||||
fileName: "photo.png",
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
});
|
||||
getImageMetadataMock.mockReset().mockResolvedValue(null);
|
||||
resizeToJpegMock.mockReset();
|
||||
setMatrixRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
it("uploads media with url payloads", async () => {
|
||||
const { client, sendMessage, uploadContent } = makeClient();
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "caption", {
|
||||
client,
|
||||
mediaUrl: "file:///tmp/photo.png",
|
||||
});
|
||||
|
||||
const uploadArg = uploadContent.mock.calls[0]?.[0];
|
||||
expect(Buffer.isBuffer(uploadArg)).toBe(true);
|
||||
|
||||
const content = sendMessage.mock.calls[0]?.[1] as {
|
||||
url?: string;
|
||||
msgtype?: string;
|
||||
format?: string;
|
||||
formatted_body?: string;
|
||||
};
|
||||
expect(content.msgtype).toBe("m.image");
|
||||
expect(content.format).toBe("org.matrix.custom.html");
|
||||
expect(content.formatted_body).toContain("caption");
|
||||
expect(content.url).toBe("mxc://example/file");
|
||||
});
|
||||
|
||||
it("uploads encrypted media with file payloads", async () => {
|
||||
const { client, sendMessage, uploadContent } = makeClient();
|
||||
(client as { crypto?: object }).crypto = {
|
||||
isRoomEncrypted: vi.fn().mockResolvedValue(true),
|
||||
encryptMedia: vi.fn().mockResolvedValue({
|
||||
buffer: Buffer.from("encrypted"),
|
||||
file: {
|
||||
key: {
|
||||
kty: "oct",
|
||||
key_ops: ["encrypt", "decrypt"],
|
||||
alg: "A256CTR",
|
||||
k: "secret",
|
||||
ext: true,
|
||||
},
|
||||
iv: "iv",
|
||||
hashes: { sha256: "hash" },
|
||||
v: "v2",
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "caption", {
|
||||
client,
|
||||
mediaUrl: "file:///tmp/photo.png",
|
||||
});
|
||||
|
||||
const uploadArg = uploadContent.mock.calls[0]?.[0] as Buffer | undefined;
|
||||
expect(uploadArg?.toString()).toBe("encrypted");
|
||||
|
||||
const content = sendMessage.mock.calls[0]?.[1] as {
|
||||
url?: string;
|
||||
file?: { url?: string };
|
||||
};
|
||||
expect(content.url).toBeUndefined();
|
||||
expect(content.file?.url).toBe("mxc://example/file");
|
||||
});
|
||||
|
||||
it("does not upload plaintext thumbnails for encrypted image sends", async () => {
|
||||
const { client, uploadContent } = makeClient();
|
||||
(client as { crypto?: object }).crypto = {
|
||||
isRoomEncrypted: vi.fn().mockResolvedValue(true),
|
||||
encryptMedia: vi.fn().mockResolvedValue({
|
||||
buffer: Buffer.from("encrypted"),
|
||||
file: {
|
||||
key: {
|
||||
kty: "oct",
|
||||
key_ops: ["encrypt", "decrypt"],
|
||||
alg: "A256CTR",
|
||||
k: "secret",
|
||||
ext: true,
|
||||
},
|
||||
iv: "iv",
|
||||
hashes: { sha256: "hash" },
|
||||
v: "v2",
|
||||
},
|
||||
}),
|
||||
};
|
||||
getImageMetadataMock
|
||||
.mockResolvedValueOnce({ width: 1600, height: 1200 })
|
||||
.mockResolvedValueOnce({ width: 800, height: 600 });
|
||||
resizeToJpegMock.mockResolvedValueOnce(Buffer.from("thumb"));
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "caption", {
|
||||
client,
|
||||
mediaUrl: "file:///tmp/photo.png",
|
||||
});
|
||||
|
||||
expect(uploadContent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uploads thumbnail metadata for unencrypted large images", async () => {
|
||||
const { client, sendMessage, uploadContent } = makeClient();
|
||||
getImageMetadataMock
|
||||
.mockResolvedValueOnce({ width: 1600, height: 1200 })
|
||||
.mockResolvedValueOnce({ width: 800, height: 600 });
|
||||
resizeToJpegMock.mockResolvedValueOnce(Buffer.from("thumb"));
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "caption", {
|
||||
client,
|
||||
mediaUrl: "file:///tmp/photo.png",
|
||||
});
|
||||
|
||||
expect(uploadContent).toHaveBeenCalledTimes(2);
|
||||
const content = sendMessage.mock.calls[0]?.[1] as {
|
||||
info?: {
|
||||
thumbnail_url?: string;
|
||||
thumbnail_info?: {
|
||||
w?: number;
|
||||
h?: number;
|
||||
mimetype?: string;
|
||||
size?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(content.info?.thumbnail_url).toBe("mxc://example/file");
|
||||
expect(content.info?.thumbnail_info).toMatchObject({
|
||||
w: 800,
|
||||
h: 600,
|
||||
mimetype: "image/jpeg",
|
||||
size: Buffer.from("thumb").byteLength,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessageMatrix threads", () => {
|
||||
beforeAll(async () => {
|
||||
setMatrixRuntime(runtimeStub);
|
||||
({ sendMessageMatrix } = await import("./send.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setMatrixRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
it("includes thread relation metadata when threadId is set", async () => {
|
||||
const { client, sendMessage } = makeClient();
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "hello thread", {
|
||||
client,
|
||||
threadId: "$thread",
|
||||
});
|
||||
|
||||
const content = sendMessage.mock.calls[0]?.[1] as {
|
||||
"m.relates_to"?: {
|
||||
rel_type?: string;
|
||||
event_id?: string;
|
||||
"m.in_reply_to"?: { event_id?: string };
|
||||
};
|
||||
};
|
||||
|
||||
expect(content["m.relates_to"]).toMatchObject({
|
||||
rel_type: "m.thread",
|
||||
event_id: "$thread",
|
||||
"m.in_reply_to": { event_id: "$thread" },
|
||||
});
|
||||
});
|
||||
});
|
||||
264
extensions/matrix-js/src/matrix/send.ts
Normal file
264
extensions/matrix-js/src/matrix/send.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import type { PollInput } from "openclaw/plugin-sdk/matrix-js";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
import { resolveMatrixClient, resolveMediaMaxBytes } from "./send/client.js";
|
||||
import {
|
||||
buildReplyRelation,
|
||||
buildTextContent,
|
||||
buildThreadRelation,
|
||||
resolveMatrixMsgType,
|
||||
resolveMatrixVoiceDecision,
|
||||
} from "./send/formatting.js";
|
||||
import {
|
||||
buildMediaContent,
|
||||
prepareImageInfo,
|
||||
resolveMediaDurationMs,
|
||||
uploadMediaMaybeEncrypted,
|
||||
} from "./send/media.js";
|
||||
import { normalizeThreadId, resolveMatrixRoomId } from "./send/targets.js";
|
||||
import {
|
||||
EventType,
|
||||
MsgType,
|
||||
RelationType,
|
||||
type MatrixOutboundContent,
|
||||
type MatrixSendOpts,
|
||||
type MatrixSendResult,
|
||||
type ReactionEventContent,
|
||||
} from "./send/types.js";
|
||||
|
||||
const MATRIX_TEXT_LIMIT = 4000;
|
||||
const getCore = () => getMatrixRuntime();
|
||||
|
||||
export type { MatrixSendOpts, MatrixSendResult } from "./send/types.js";
|
||||
export { resolveMatrixRoomId } from "./send/targets.js";
|
||||
|
||||
export async function sendMessageMatrix(
|
||||
to: string,
|
||||
message: string,
|
||||
opts: MatrixSendOpts = {},
|
||||
): Promise<MatrixSendResult> {
|
||||
const trimmedMessage = message?.trim() ?? "";
|
||||
if (!trimmedMessage && !opts.mediaUrl) {
|
||||
throw new Error("Matrix send requires text or media");
|
||||
}
|
||||
const { client, stopOnDone } = await resolveMatrixClient({
|
||||
client: opts.client,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
try {
|
||||
const roomId = await resolveMatrixRoomId(client, to);
|
||||
const cfg = getCore().config.loadConfig();
|
||||
const tableMode = getCore().channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "matrix-js",
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const convertedMessage = getCore().channel.text.convertMarkdownTables(
|
||||
trimmedMessage,
|
||||
tableMode,
|
||||
);
|
||||
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix-js");
|
||||
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
||||
const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix-js", opts.accountId);
|
||||
const chunks = getCore().channel.text.chunkMarkdownTextWithMode(
|
||||
convertedMessage,
|
||||
chunkLimit,
|
||||
chunkMode,
|
||||
);
|
||||
const threadId = normalizeThreadId(opts.threadId);
|
||||
const relation = threadId
|
||||
? buildThreadRelation(threadId, opts.replyToId)
|
||||
: buildReplyRelation(opts.replyToId);
|
||||
const sendContent = async (content: MatrixOutboundContent) => {
|
||||
const eventId = await client.sendMessage(roomId, content);
|
||||
return eventId;
|
||||
};
|
||||
|
||||
let lastMessageId = "";
|
||||
if (opts.mediaUrl) {
|
||||
const maxBytes = resolveMediaMaxBytes(opts.accountId);
|
||||
const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
|
||||
const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, {
|
||||
contentType: media.contentType,
|
||||
filename: media.fileName,
|
||||
});
|
||||
const durationMs = await resolveMediaDurationMs({
|
||||
buffer: media.buffer,
|
||||
contentType: media.contentType,
|
||||
fileName: media.fileName,
|
||||
kind: media.kind,
|
||||
});
|
||||
const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName);
|
||||
const { useVoice } = resolveMatrixVoiceDecision({
|
||||
wantsVoice: opts.audioAsVoice === true,
|
||||
contentType: media.contentType,
|
||||
fileName: media.fileName,
|
||||
});
|
||||
const msgtype = useVoice ? MsgType.Audio : baseMsgType;
|
||||
const isImage = msgtype === MsgType.Image;
|
||||
const imageInfo = isImage
|
||||
? await prepareImageInfo({
|
||||
buffer: media.buffer,
|
||||
client,
|
||||
encrypted: Boolean(uploaded.file),
|
||||
})
|
||||
: undefined;
|
||||
const [firstChunk, ...rest] = chunks;
|
||||
const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)");
|
||||
const content = buildMediaContent({
|
||||
msgtype,
|
||||
body,
|
||||
url: uploaded.url,
|
||||
file: uploaded.file,
|
||||
filename: media.fileName,
|
||||
mimetype: media.contentType,
|
||||
size: media.buffer.byteLength,
|
||||
durationMs,
|
||||
relation,
|
||||
isVoice: useVoice,
|
||||
imageInfo,
|
||||
});
|
||||
const eventId = await sendContent(content);
|
||||
lastMessageId = eventId ?? lastMessageId;
|
||||
const textChunks = useVoice ? chunks : rest;
|
||||
const followupRelation = threadId ? relation : undefined;
|
||||
for (const chunk of textChunks) {
|
||||
const text = chunk.trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const followup = buildTextContent(text, followupRelation);
|
||||
const followupEventId = await sendContent(followup);
|
||||
lastMessageId = followupEventId ?? lastMessageId;
|
||||
}
|
||||
} else {
|
||||
for (const chunk of chunks.length ? chunks : [""]) {
|
||||
const text = chunk.trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const content = buildTextContent(text, relation);
|
||||
const eventId = await sendContent(content);
|
||||
lastMessageId = eventId ?? lastMessageId;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: lastMessageId || "unknown",
|
||||
roomId,
|
||||
};
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPollMatrix(
|
||||
to: string,
|
||||
poll: PollInput,
|
||||
opts: MatrixSendOpts = {},
|
||||
): Promise<{ eventId: string; roomId: string }> {
|
||||
if (!poll.question?.trim()) {
|
||||
throw new Error("Matrix poll requires a question");
|
||||
}
|
||||
if (!poll.options?.length) {
|
||||
throw new Error("Matrix poll requires options");
|
||||
}
|
||||
const { client, stopOnDone } = await resolveMatrixClient({
|
||||
client: opts.client,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
|
||||
try {
|
||||
const roomId = await resolveMatrixRoomId(client, to);
|
||||
const pollContent = buildPollStartContent(poll);
|
||||
const threadId = normalizeThreadId(opts.threadId);
|
||||
const pollPayload = threadId
|
||||
? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) }
|
||||
: pollContent;
|
||||
const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload);
|
||||
|
||||
return {
|
||||
eventId: eventId ?? "unknown",
|
||||
roomId,
|
||||
};
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendTypingMatrix(
|
||||
roomId: string,
|
||||
typing: boolean,
|
||||
timeoutMs?: number,
|
||||
client?: MatrixClient,
|
||||
): Promise<void> {
|
||||
const { client: resolved, stopOnDone } = await resolveMatrixClient({
|
||||
client,
|
||||
timeoutMs,
|
||||
});
|
||||
try {
|
||||
const resolvedTimeoutMs = typeof timeoutMs === "number" ? timeoutMs : 30_000;
|
||||
await resolved.setTyping(roomId, typing, resolvedTimeoutMs);
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
resolved.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendReadReceiptMatrix(
|
||||
roomId: string,
|
||||
eventId: string,
|
||||
client?: MatrixClient,
|
||||
): Promise<void> {
|
||||
if (!eventId?.trim()) {
|
||||
return;
|
||||
}
|
||||
const { client: resolved, stopOnDone } = await resolveMatrixClient({
|
||||
client,
|
||||
});
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(resolved, roomId);
|
||||
await resolved.sendReadReceipt(resolvedRoom, eventId.trim());
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
resolved.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function reactMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
client?: MatrixClient,
|
||||
): Promise<void> {
|
||||
if (!emoji.trim()) {
|
||||
throw new Error("Matrix reaction requires an emoji");
|
||||
}
|
||||
const { client: resolved, stopOnDone } = await resolveMatrixClient({
|
||||
client,
|
||||
});
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(resolved, roomId);
|
||||
const reaction: ReactionEventContent = {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: messageId,
|
||||
key: emoji,
|
||||
},
|
||||
};
|
||||
await resolved.sendEvent(resolvedRoom, EventType.Reaction, reaction);
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
resolved.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
78
extensions/matrix-js/src/matrix/send/client.test.ts
Normal file
78
extensions/matrix-js/src/matrix/send/client.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
|
||||
const getActiveMatrixClientMock = vi.fn();
|
||||
const createMatrixClientMock = vi.fn();
|
||||
const isBunRuntimeMock = vi.fn(() => false);
|
||||
const resolveMatrixAuthMock = vi.fn();
|
||||
|
||||
vi.mock("../active-client.js", () => ({
|
||||
getActiveMatrixClient: (...args: unknown[]) => getActiveMatrixClientMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../client.js", () => ({
|
||||
createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args),
|
||||
isBunRuntime: () => isBunRuntimeMock(),
|
||||
resolveMatrixAuth: (...args: unknown[]) => resolveMatrixAuthMock(...args),
|
||||
}));
|
||||
|
||||
let resolveMatrixClient: typeof import("./client.js").resolveMatrixClient;
|
||||
|
||||
function createMockMatrixClient(): MatrixClient {
|
||||
return {
|
||||
prepareForOneOff: vi.fn(async () => undefined),
|
||||
} as unknown as MatrixClient;
|
||||
}
|
||||
|
||||
describe("resolveMatrixClient", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
getActiveMatrixClientMock.mockReturnValue(null);
|
||||
isBunRuntimeMock.mockReturnValue(false);
|
||||
resolveMatrixAuthMock.mockResolvedValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
password: undefined,
|
||||
deviceId: "DEVICE123",
|
||||
encryption: false,
|
||||
});
|
||||
createMatrixClientMock.mockResolvedValue(createMockMatrixClient());
|
||||
|
||||
({ resolveMatrixClient } = await import("./client.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("creates a one-off client even when OPENCLAW_GATEWAY_PORT is set", async () => {
|
||||
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799");
|
||||
|
||||
const result = await resolveMatrixClient({ accountId: "default" });
|
||||
|
||||
expect(getActiveMatrixClientMock).toHaveBeenCalledWith("default");
|
||||
expect(resolveMatrixAuthMock).toHaveBeenCalledTimes(1);
|
||||
expect(createMatrixClientMock).toHaveBeenCalledTimes(1);
|
||||
expect(createMatrixClientMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
autoBootstrapCrypto: false,
|
||||
}),
|
||||
);
|
||||
const oneOffClient = await createMatrixClientMock.mock.results[0]?.value;
|
||||
expect(oneOffClient.prepareForOneOff).toHaveBeenCalledTimes(1);
|
||||
expect(result.stopOnDone).toBe(true);
|
||||
});
|
||||
|
||||
it("reuses active monitor client when available", async () => {
|
||||
const activeClient = createMockMatrixClient();
|
||||
getActiveMatrixClientMock.mockReturnValue(activeClient);
|
||||
|
||||
const result = await resolveMatrixClient({ accountId: "default" });
|
||||
|
||||
expect(result).toEqual({ client: activeClient, stopOnDone: false });
|
||||
expect(resolveMatrixAuthMock).not.toHaveBeenCalled();
|
||||
expect(createMatrixClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user