Plugins: scope SDK imports and harden Matrix routing

This commit is contained in:
Gustavo Madeira Santana
2026-03-09 00:54:57 -04:00
parent 3e6dd9a2ff
commit e4900d6f7f
42 changed files with 775 additions and 203 deletions

View File

@@ -29,4 +29,5 @@ openclaw pairing approve --channel telegram --account work <code> --notify
- Channel input: pass it positionally (`pairing list telegram`) or with `--channel <channel>`.
- `pairing list` supports `--account <accountId>` for multi-account channels.
- `pairing approve` supports `--account <accountId>` and `--notify`.
- When you combine `pairing approve --account <accountId> --notify`, OpenClaw sends the approval notice from that same account.
- If only one pairing-capable channel is configured, `pairing approve <code>` is allowed.

View File

@@ -14,6 +14,7 @@ async function main() {
password: auth.password,
deviceId: auth.deviceId,
encryption: false,
accountId: auth.accountId,
});
const targetUserId = process.argv[2]?.trim() || "@user:example.org";

View File

@@ -25,6 +25,7 @@ async function main() {
password: auth.password,
deviceId: auth.deviceId,
encryption: true,
accountId: auth.accountId,
});
const initCrypto = (client as unknown as { initializeCryptoIfNeeded?: () => Promise<void> })
.initializeCryptoIfNeeded;

View File

@@ -23,6 +23,7 @@ async function main() {
password: auth.password,
deviceId: auth.deviceId,
encryption: false,
accountId: auth.accountId,
});
try {

View File

@@ -31,6 +31,7 @@ async function main() {
password: auth.password,
deviceId: auth.deviceId,
encryption: true,
accountId: auth.accountId,
});
const stamp = new Date().toISOString();

View File

@@ -26,6 +26,7 @@ async function main() {
password: auth.password,
deviceId: auth.deviceId,
encryption: true,
accountId: auth.accountId,
});
const stamp = new Date().toISOString();

View File

@@ -40,6 +40,7 @@ async function main() {
password: auth.password,
deviceId: auth.deviceId,
encryption: true,
accountId: auth.accountId,
});
try {

View File

@@ -21,6 +21,7 @@ async function main() {
password: auth.password,
deviceId: auth.deviceId,
encryption: false,
accountId: auth.accountId,
});
try {

View File

@@ -0,0 +1,90 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMessageMatrixMock = vi.hoisted(() => vi.fn());
const probeMatrixMock = vi.hoisted(() => vi.fn());
const resolveMatrixAuthMock = vi.hoisted(() => vi.fn());
vi.mock("./matrix/send.js", async () => {
const actual = await vi.importActual<typeof import("./matrix/send.js")>("./matrix/send.js");
return {
...actual,
sendMessageMatrix: (...args: unknown[]) => sendMessageMatrixMock(...args),
};
});
vi.mock("./matrix/probe.js", async () => {
const actual = await vi.importActual<typeof import("./matrix/probe.js")>("./matrix/probe.js");
return {
...actual,
probeMatrix: (...args: unknown[]) => probeMatrixMock(...args),
};
});
vi.mock("./matrix/client.js", async () => {
const actual = await vi.importActual<typeof import("./matrix/client.js")>("./matrix/client.js");
return {
...actual,
resolveMatrixAuth: (...args: unknown[]) => resolveMatrixAuthMock(...args),
};
});
const { matrixPlugin } = await import("./channel.js");
describe("matrix account path propagation", () => {
beforeEach(() => {
vi.clearAllMocks();
sendMessageMatrixMock.mockResolvedValue({
messageId: "$sent",
roomId: "!room:example.org",
});
probeMatrixMock.mockResolvedValue({
ok: true,
error: null,
status: null,
elapsedMs: 5,
userId: "@poe:example.org",
});
resolveMatrixAuthMock.mockResolvedValue({
accountId: "poe",
homeserver: "https://matrix.example.org",
userId: "@poe:example.org",
accessToken: "poe-token",
});
});
it("forwards accountId when notifying pairing approval", async () => {
await matrixPlugin.pairing!.notifyApproval?.({
cfg: {},
id: "@user:example.org",
accountId: "poe",
});
expect(sendMessageMatrixMock).toHaveBeenCalledWith(
"user:@user:example.org",
expect.any(String),
{ accountId: "poe" },
);
});
it("forwards accountId to matrix probes", async () => {
await matrixPlugin.status!.probeAccount?.({
cfg: {} as never,
timeoutMs: 500,
account: {
accountId: "poe",
} as never,
});
expect(resolveMatrixAuthMock).toHaveBeenCalledWith({
cfg: {},
accountId: "poe",
});
expect(probeMatrixMock).toHaveBeenCalledWith({
homeserver: "https://matrix.example.org",
accessToken: "poe-token",
userId: "@poe:example.org",
timeoutMs: 500,
accountId: "poe",
});
});
});

View File

@@ -12,6 +12,7 @@ describe("matrix directory live", () => {
beforeEach(() => {
vi.mocked(resolveMatrixAuth).mockReset();
vi.mocked(resolveMatrixAuth).mockResolvedValue({
accountId: "assistant",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "test-token",

View File

@@ -42,6 +42,7 @@ describe("resolveActionClient", () => {
getActiveMatrixClientMock.mockReturnValue(null);
isBunRuntimeMock.mockReturnValue(false);
resolveMatrixAuthMock.mockResolvedValue({
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
@@ -53,7 +54,7 @@ describe("resolveActionClient", () => {
({ cfg, accountId }: { cfg: unknown; accountId?: string | null }) => ({
cfg,
env: process.env,
accountId: accountId ?? undefined,
accountId: accountId ?? "default",
resolved: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
@@ -129,6 +130,7 @@ describe("resolveActionClient", () => {
},
});
resolveMatrixAuthMock.mockResolvedValue({
accountId: "ops",
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",

View File

@@ -43,7 +43,7 @@ export async function resolveActionClient(
deviceId: auth.deviceId,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
accountId: authContext.accountId,
accountId: auth.accountId,
autoBootstrapCrypto: false,
});
await client.prepareForOneOff();

View File

@@ -0,0 +1,228 @@
import { describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
import { readMatrixMessages } from "./messages.js";
function createMessagesClient(params: {
chunk: Array<Record<string, unknown>>;
hydratedChunk?: Array<Record<string, unknown>>;
pollRoot?: Record<string, unknown>;
pollRelations?: Array<Record<string, unknown>>;
}) {
const doRequest = vi.fn(async () => ({
chunk: params.chunk,
start: "start-token",
end: "end-token",
}));
const hydrateEvents = vi.fn(
async (_roomId: string, _events: Array<Record<string, unknown>>) =>
(params.hydratedChunk ?? params.chunk) as any,
);
const getEvent = vi.fn(async () => params.pollRoot ?? null);
const getRelations = vi.fn(async () => ({
events: params.pollRelations ?? [],
nextBatch: null,
prevBatch: null,
}));
return {
client: {
doRequest,
hydrateEvents,
getEvent,
getRelations,
stop: vi.fn(),
} as unknown as MatrixClient,
doRequest,
hydrateEvents,
getEvent,
getRelations,
};
}
describe("matrix message actions", () => {
it("includes poll snapshots when reading message history", async () => {
const { client, doRequest, getEvent, getRelations } = createMessagesClient({
chunk: [
{
event_id: "$vote",
sender: "@bob:example.org",
type: "m.poll.response",
origin_server_ts: 20,
content: {
"m.poll.response": { answers: ["a1"] },
"m.relates_to": { rel_type: "m.reference", event_id: "$poll" },
},
},
{
event_id: "$msg",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 10,
content: {
msgtype: "m.text",
body: "hello",
},
},
],
pollRoot: {
event_id: "$poll",
sender: "@alice:example.org",
type: "m.poll.start",
origin_server_ts: 1,
content: {
"m.poll.start": {
question: { "m.text": "Favorite fruit?" },
kind: "m.poll.disclosed",
max_selections: 1,
answers: [
{ id: "a1", "m.text": "Apple" },
{ id: "a2", "m.text": "Strawberry" },
],
},
},
},
pollRelations: [
{
event_id: "$vote",
sender: "@bob:example.org",
type: "m.poll.response",
origin_server_ts: 20,
content: {
"m.poll.response": { answers: ["a1"] },
"m.relates_to": { rel_type: "m.reference", event_id: "$poll" },
},
},
],
});
const result = await readMatrixMessages("room:!room:example.org", { client, limit: 2.9 });
expect(doRequest).toHaveBeenCalledWith(
"GET",
expect.stringContaining("/rooms/!room%3Aexample.org/messages"),
expect.objectContaining({ limit: 2 }),
);
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll");
expect(getRelations).toHaveBeenCalledWith(
"!room:example.org",
"$poll",
"m.reference",
undefined,
{
from: undefined,
},
);
expect(result.messages).toEqual([
expect.objectContaining({
eventId: "$poll",
body: expect.stringContaining("1. Apple (1 vote)"),
msgtype: "m.text",
}),
expect.objectContaining({
eventId: "$msg",
body: "hello",
}),
]);
});
it("dedupes multiple poll events for the same poll within one read page", async () => {
const { client, getEvent } = createMessagesClient({
chunk: [
{
event_id: "$vote",
sender: "@bob:example.org",
type: "m.poll.response",
origin_server_ts: 20,
content: {
"m.poll.response": { answers: ["a1"] },
"m.relates_to": { rel_type: "m.reference", event_id: "$poll" },
},
},
{
event_id: "$poll",
sender: "@alice:example.org",
type: "m.poll.start",
origin_server_ts: 1,
content: {
"m.poll.start": {
question: { "m.text": "Favorite fruit?" },
answers: [{ id: "a1", "m.text": "Apple" }],
},
},
},
],
pollRoot: {
event_id: "$poll",
sender: "@alice:example.org",
type: "m.poll.start",
origin_server_ts: 1,
content: {
"m.poll.start": {
question: { "m.text": "Favorite fruit?" },
answers: [{ id: "a1", "m.text": "Apple" }],
},
},
},
pollRelations: [],
});
const result = await readMatrixMessages("room:!room:example.org", { client });
expect(result.messages).toHaveLength(1);
expect(result.messages[0]).toEqual(
expect.objectContaining({
eventId: "$poll",
body: expect.stringContaining("[Poll]"),
}),
);
expect(getEvent).toHaveBeenCalledTimes(1);
});
it("uses hydrated history events so encrypted poll entries can be read", async () => {
const { client, hydrateEvents } = createMessagesClient({
chunk: [
{
event_id: "$enc",
sender: "@bob:example.org",
type: "m.room.encrypted",
origin_server_ts: 20,
content: {},
},
],
hydratedChunk: [
{
event_id: "$vote",
sender: "@bob:example.org",
type: "m.poll.response",
origin_server_ts: 20,
content: {
"m.poll.response": { answers: ["a1"] },
"m.relates_to": { rel_type: "m.reference", event_id: "$poll" },
},
},
],
pollRoot: {
event_id: "$poll",
sender: "@alice:example.org",
type: "m.poll.start",
origin_server_ts: 1,
content: {
"m.poll.start": {
question: { "m.text": "Favorite fruit?" },
answers: [{ id: "a1", "m.text": "Apple" }],
},
},
},
pollRelations: [],
});
const result = await readMatrixMessages("room:!room:example.org", { client });
expect(hydrateEvents).toHaveBeenCalledWith(
"!room:example.org",
expect.arrayContaining([expect.objectContaining({ event_id: "$enc" })]),
);
expect(result.messages).toHaveLength(1);
expect(result.messages[0]?.eventId).toBe("$poll");
});
});

View File

@@ -1,3 +1,5 @@
import { fetchMatrixPollMessageSummary, resolveMatrixPollRootEventId } from "../poll-summary.js";
import { isPollEventType } from "../poll-types.js";
import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js";
import { withResolvedActionClient } from "./client.js";
import { resolveMatrixActionLimit } from "./limits.js";
@@ -99,10 +101,30 @@ export async function readMatrixMessages(
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);
const hydratedChunk = await client.hydrateEvents(resolvedRoom, res.chunk);
const seenPollRoots = new Set<string>();
const messages: MatrixMessageSummary[] = [];
for (const event of hydratedChunk) {
if (event.unsigned?.redacted_because) {
continue;
}
if (event.type === EventType.RoomMessage) {
messages.push(summarizeMatrixRawEvent(event));
continue;
}
if (!isPollEventType(event.type)) {
continue;
}
const pollRootId = resolveMatrixPollRootEventId(event);
if (!pollRootId || seenPollRoots.has(pollRootId)) {
continue;
}
seenPollRoots.add(pollRootId);
const pollSummary = await fetchMatrixPollMessageSummary(client, resolvedRoom, event);
if (pollSummary) {
messages.push(pollSummary);
}
}
return {
messages,
nextBatch: res.end ?? null,

View File

@@ -1,3 +1,4 @@
import { fetchMatrixPollMessageSummary } from "../poll-summary.js";
import type { MatrixClient } from "../sdk.js";
import {
EventType,
@@ -67,6 +68,10 @@ export async function fetchEventSummary(
if (raw.unsigned?.redacted_because) {
return null;
}
const pollSummary = await fetchMatrixPollMessageSummary(client, roomId, raw);
if (pollSummary) {
return pollSummary;
}
return summarizeMatrixRawEvent(raw);
} catch {
// Event not found, redacted, or inaccessible - return null

View File

@@ -1,11 +1,5 @@
import { createMatrixClient } from "./client.js";
type MatrixClientBootstrapAuth = {
homeserver: string;
userId: string;
accessToken: string;
encryption?: boolean;
};
import type { MatrixAuth } from "./client/types.js";
type MatrixCryptoPrepare = {
prepare: (rooms?: string[]) => Promise<void>;
@@ -14,9 +8,8 @@ type MatrixCryptoPrepare = {
type MatrixBootstrapClient = Awaited<ReturnType<typeof createMatrixClient>>;
export async function createPreparedMatrixClient(opts: {
auth: MatrixClientBootstrapAuth;
auth: Pick<MatrixAuth, "accountId" | "homeserver" | "userId" | "accessToken" | "encryption">;
timeoutMs?: number;
accountId?: string;
}): Promise<MatrixBootstrapClient> {
const client = await createMatrixClient({
homeserver: opts.auth.homeserver,
@@ -24,7 +17,7 @@ export async function createPreparedMatrixClient(opts: {
accessToken: opts.auth.accessToken,
encryption: opts.auth.encryption,
localTimeoutMs: opts.timeoutMs,
accountId: opts.accountId,
accountId: opts.auth.accountId,
});
if (opts.auth.encryption && client.crypto) {
try {

View File

@@ -130,6 +130,7 @@ describe("resolveMatrixAuth", () => {
}),
);
expect(auth).toMatchObject({
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
@@ -144,7 +145,7 @@ describe("resolveMatrixAuth", () => {
deviceId: "DEVICE123",
}),
expect.any(Object),
undefined,
"default",
);
});
@@ -206,6 +207,7 @@ describe("resolveMatrixAuth", () => {
});
expect(auth).toMatchObject({
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "cached-token",
@@ -238,6 +240,7 @@ describe("resolveMatrixAuth", () => {
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(auth.deviceId).toBe("DEVICE123");
expect(auth.accountId).toBe("default");
expect(saveMatrixCredentialsMock).toHaveBeenCalledWith(
expect.objectContaining({
homeserver: "https://matrix.example.org",
@@ -246,7 +249,7 @@ describe("resolveMatrixAuth", () => {
deviceId: "DEVICE123",
}),
expect.any(Object),
undefined,
"default",
);
});
@@ -273,6 +276,7 @@ describe("resolveMatrixAuth", () => {
expect(doRequestSpy).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
expect(auth).toMatchObject({
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
@@ -304,6 +308,7 @@ describe("resolveMatrixAuth", () => {
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(auth).toMatchObject({
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
@@ -332,6 +337,7 @@ describe("resolveMatrixAuth", () => {
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(auth).toMatchObject({
accountId: "ops",
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",

View File

@@ -291,7 +291,7 @@ export function resolveMatrixAuthContext(params?: {
}): {
cfg: CoreConfig;
env: NodeJS.ProcessEnv;
accountId?: string;
accountId: string;
resolved: MatrixResolvedConfig;
} {
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
@@ -301,11 +301,12 @@ export function resolveMatrixAuthContext(params?: {
const effectiveAccountId =
explicitAccountId ??
(defaultResolved.homeserver
? undefined
: (resolveImplicitMatrixAccountId(cfg, env) ?? undefined));
const resolved = effectiveAccountId
? resolveMatrixConfigForAccount(cfg, effectiveAccountId, env)
: defaultResolved;
? DEFAULT_ACCOUNT_ID
: (resolveImplicitMatrixAccountId(cfg, env) ?? DEFAULT_ACCOUNT_ID));
const resolved =
effectiveAccountId === DEFAULT_ACCOUNT_ID && defaultResolved.homeserver
? defaultResolved
: resolveMatrixConfigForAccount(cfg, effectiveAccountId, env);
return {
cfg,
@@ -390,6 +391,7 @@ export async function resolveMatrixAuth(params?: {
await touchMatrixCredentials(env, accountId);
}
return {
accountId,
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
@@ -404,6 +406,7 @@ export async function resolveMatrixAuth(params?: {
if (cachedCredentials) {
await touchMatrixCredentials(env, accountId);
return {
accountId,
homeserver: cachedCredentials.homeserver,
userId: cachedCredentials.userId,
accessToken: cachedCredentials.accessToken,
@@ -446,6 +449,7 @@ export async function resolveMatrixAuth(params?: {
}
const auth: MatrixAuth = {
accountId,
homeserver: resolved.homeserver,
userId: login.user_id ?? resolved.userId,
accessToken,

View File

@@ -2,10 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixAuth } from "./types.js";
const resolveMatrixAuthMock = vi.hoisted(() => vi.fn());
const resolveMatrixAuthContextMock = vi.hoisted(() => vi.fn());
const createMatrixClientMock = vi.hoisted(() => vi.fn());
vi.mock("./config.js", () => ({
resolveMatrixAuth: resolveMatrixAuthMock,
resolveMatrixAuthContext: resolveMatrixAuthContextMock,
}));
vi.mock("./create-client.js", () => ({
@@ -20,6 +22,7 @@ import {
function authFor(accountId: string): MatrixAuth {
return {
accountId,
homeserver: "https://matrix.example.org",
userId: `@${accountId}:example.org`,
accessToken: `token-${accountId}`,
@@ -45,7 +48,16 @@ function createMockClient(name: string) {
describe("resolveSharedMatrixClient", () => {
beforeEach(() => {
resolveMatrixAuthMock.mockReset();
resolveMatrixAuthContextMock.mockReset();
createMatrixClientMock.mockReset();
resolveMatrixAuthContextMock.mockImplementation(
({ accountId }: { accountId?: string | null } = {}) => ({
cfg: undefined,
env: undefined,
accountId: accountId ?? "default",
resolved: {},
}),
);
});
afterEach(() => {
@@ -100,7 +112,7 @@ describe("resolveSharedMatrixClient", () => {
await resolveSharedMatrixClient({ accountId: "main", startClient: false });
await resolveSharedMatrixClient({ accountId: "ops", startClient: false });
stopSharedClientForAccount(mainAuth, "main");
stopSharedClientForAccount(mainAuth);
expect(mainClient.stop).toHaveBeenCalledTimes(1);
expect(poeClient.stop).toHaveBeenCalledTimes(0);
@@ -109,4 +121,45 @@ describe("resolveSharedMatrixClient", () => {
expect(poeClient.stop).toHaveBeenCalledTimes(1);
});
it("reuses the effective implicit account instead of keying it as default", async () => {
const poeAuth = authFor("ops");
const poeClient = createMockClient("ops");
resolveMatrixAuthContextMock.mockReturnValue({
cfg: undefined,
env: undefined,
accountId: "ops",
resolved: {},
});
resolveMatrixAuthMock.mockResolvedValue(poeAuth);
createMatrixClientMock.mockResolvedValue(poeClient);
const first = await resolveSharedMatrixClient({ startClient: false });
const second = await resolveSharedMatrixClient({ startClient: false });
expect(first).toBe(poeClient);
expect(second).toBe(poeClient);
expect(resolveMatrixAuthMock).toHaveBeenCalledWith({
cfg: undefined,
env: undefined,
accountId: "ops",
});
expect(createMatrixClientMock).toHaveBeenCalledTimes(1);
expect(createMatrixClientMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "ops",
}),
);
});
it("rejects mismatched explicit account ids when auth is already resolved", async () => {
await expect(
resolveSharedMatrixClient({
auth: authFor("ops"),
accountId: "main",
startClient: false,
}),
).rejects.toThrow("Matrix shared client account mismatch");
});
});

View File

@@ -1,9 +1,9 @@
import { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id";
import type { CoreConfig } from "../../types.js";
import type { MatrixClient } from "../sdk.js";
import { LogService } from "../sdk/logger.js";
import { resolveMatrixAuth } from "./config.js";
import { resolveMatrixAuth, resolveMatrixAuthContext } from "./config.js";
import { createMatrixClient } from "./create-client.js";
import { DEFAULT_ACCOUNT_KEY } from "./storage.js";
import type { MatrixAuth } from "./types.js";
type SharedMatrixClientState = {
@@ -17,20 +17,19 @@ type SharedMatrixClientState = {
const sharedClientStates = new Map<string, SharedMatrixClientState>();
const sharedClientPromises = new Map<string, Promise<SharedMatrixClientState>>();
function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string {
function buildSharedClientKey(auth: MatrixAuth): string {
return [
auth.homeserver,
auth.userId,
auth.accessToken,
auth.encryption ? "e2ee" : "plain",
accountId ?? DEFAULT_ACCOUNT_KEY,
auth.accountId,
].join("|");
}
async function createSharedMatrixClient(params: {
auth: MatrixAuth;
timeoutMs?: number;
accountId?: string | null;
}): Promise<SharedMatrixClientState> {
const client = await createMatrixClient({
homeserver: params.auth.homeserver,
@@ -41,11 +40,11 @@ async function createSharedMatrixClient(params: {
encryption: params.auth.encryption,
localTimeoutMs: params.timeoutMs,
initialSyncLimit: params.auth.initialSyncLimit,
accountId: params.accountId,
accountId: params.auth.accountId,
});
return {
client,
key: buildSharedClientKey(params.auth, params.accountId),
key: buildSharedClientKey(params.auth),
started: false,
cryptoReady: false,
startPromise: null,
@@ -103,14 +102,27 @@ export async function resolveSharedMatrixClient(
accountId?: string | null;
} = {},
): Promise<MatrixClient> {
const requestedAccountId = normalizeOptionalAccountId(params.accountId);
if (params.auth && requestedAccountId && requestedAccountId !== params.auth.accountId) {
throw new Error(
`Matrix shared client account mismatch: requested ${requestedAccountId}, auth resolved ${params.auth.accountId}`,
);
}
const authContext = params.auth
? null
: resolveMatrixAuthContext({
cfg: params.cfg,
env: params.env,
accountId: params.accountId,
});
const auth =
params.auth ??
(await resolveMatrixAuth({
cfg: params.cfg,
env: params.env,
accountId: params.accountId,
cfg: authContext?.cfg ?? params.cfg,
env: authContext?.env ?? params.env,
accountId: authContext?.accountId,
}));
const key = buildSharedClientKey(auth, params.accountId);
const key = buildSharedClientKey(auth);
const shouldStart = params.startClient !== false;
const existingState = sharedClientStates.get(key);
@@ -143,7 +155,6 @@ export async function resolveSharedMatrixClient(
const creationPromise = createSharedMatrixClient({
auth,
timeoutMs: params.timeoutMs,
accountId: params.accountId,
});
sharedClientPromises.set(key, creationPromise);
@@ -181,8 +192,8 @@ export function stopSharedClient(): void {
sharedClientPromises.clear();
}
export function stopSharedClientForAccount(auth: MatrixAuth, accountId?: string | null): void {
const key = buildSharedClientKey(auth, accountId);
export function stopSharedClientForAccount(auth: MatrixAuth): void {
const key = buildSharedClientKey(auth);
const state = sharedClientStates.get(key);
if (!state) {
return;

View File

@@ -17,6 +17,7 @@ export type MatrixResolvedConfig = {
* both will need to be recreated together.
*/
export type MatrixAuth = {
accountId: string;
homeserver: string;
userId: string;
accessToken: string;

View File

@@ -45,7 +45,7 @@ function createHarness(params?: {
registerMatrixMonitorEvents({
client,
auth: { encryption: true } as MatrixAuth,
auth: { accountId: "default", encryption: true } as MatrixAuth,
logVerboseMessage: vi.fn(),
warnedEncryptedRooms: new Set<string>(),
warnedCryptoMissingRooms: new Set<string>(),

View File

@@ -99,6 +99,7 @@ describe("createMatrixRoomMessageHandler inbound body formatting", () => {
logger: {
info: () => {},
warn: () => {},
error: () => {},
} as RuntimeLogger,
logVerboseMessage: () => {},
allowFrom: [],
@@ -247,6 +248,7 @@ describe("createMatrixRoomMessageHandler inbound body formatting", () => {
logger: {
info: () => {},
warn: () => {},
error: () => {},
} as RuntimeLogger,
logVerboseMessage: () => {},
allowFrom: [],

View File

@@ -15,16 +15,8 @@ import {
type RuntimeLogger,
} from "openclaw/plugin-sdk/matrix";
import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js";
import {
formatPollAsText,
formatPollResultsAsText,
isPollEventType,
isPollStartType,
parsePollStartContent,
resolvePollReferenceEventId,
buildPollResultsSummary,
type PollStartContent,
} from "../poll-types.js";
import { fetchMatrixPollSnapshot } from "../poll-summary.js";
import { isPollEventType } from "../poll-types.js";
import type { LocationMessageEventContent, MatrixClient } from "../sdk.js";
import {
reactMatrixMessage,
@@ -217,60 +209,18 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
let content = event.content as RoomMessageEventContent;
if (isPollEvent) {
const pollEventId = isPollStartType(eventType)
? (event.event_id ?? "")
: resolvePollReferenceEventId(event.content);
if (!pollEventId) {
return;
}
const pollEvent = isPollStartType(eventType)
? event
: await client.getEvent(roomId, pollEventId).catch((err) => {
logVerboseMessage(
`matrix: failed resolving poll root room=${roomId} id=${pollEventId}: ${String(err)}`,
);
return null;
});
if (
!pollEvent ||
!isPollStartType(typeof pollEvent.type === "string" ? pollEvent.type : "")
) {
return;
}
const pollStartContent = pollEvent.content as PollStartContent;
const pollSummary = parsePollStartContent(pollStartContent);
if (!pollSummary) {
return;
}
pollSummary.eventId = pollEventId;
pollSummary.roomId = roomId;
pollSummary.sender = typeof pollEvent.sender === "string" ? pollEvent.sender : senderId;
pollSummary.senderName = await getMemberDisplayName(roomId, pollSummary.sender);
const relationEvents: MatrixRawEvent[] = [];
let nextBatch: string | undefined;
do {
const page = await client.getRelations(roomId, pollEventId, "m.reference", undefined, {
from: nextBatch,
});
relationEvents.push(...page.events);
nextBatch = page.nextBatch ?? undefined;
} while (nextBatch);
const pollResults = buildPollResultsSummary({
pollEventId,
roomId,
sender: pollSummary.sender,
senderName: pollSummary.senderName,
content: pollStartContent,
relationEvents,
const pollSnapshot = await fetchMatrixPollSnapshot(client, roomId, event).catch((err) => {
logVerboseMessage(
`matrix: failed resolving poll snapshot room=${roomId} id=${event.event_id ?? "unknown"}: ${String(err)}`,
);
return null;
});
const pollText = pollResults
? formatPollResultsAsText(pollResults)
: formatPollAsText(pollSummary);
if (!pollSnapshot) {
return;
}
content = {
msgtype: "m.text",
body: pollText,
body: pollSnapshot.text,
} as unknown as RoomMessageEventContent;
}

View File

@@ -18,6 +18,7 @@ import { setActiveMatrixClient } from "../active-client.js";
import {
isBunRuntime,
resolveMatrixAuth,
resolveMatrixAuthContext,
resolveSharedMatrixClient,
stopSharedClientForAccount,
} from "../client.js";
@@ -137,8 +138,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
return allowList.map(String);
};
const authContext = resolveMatrixAuthContext({
cfg,
accountId: opts.accountId,
});
const effectiveAccountId = authContext.accountId;
// Resolve account-specific config for multi-account support
const account = resolveMatrixAccount({ cfg, accountId: opts.accountId });
const account = resolveMatrixAccount({ cfg, accountId: effectiveAccountId });
const accountConfig = account.config;
const allowlistOnly = accountConfig.allowlistOnly === true;
@@ -239,7 +246,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
},
};
const auth = await resolveMatrixAuth({ cfg, accountId: opts.accountId });
const auth = await resolveMatrixAuth({ cfg, accountId: effectiveAccountId });
const resolvedInitialSyncLimit =
typeof opts.initialSyncLimit === "number"
? Math.max(0, Math.floor(opts.initialSyncLimit))
@@ -252,9 +259,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
cfg,
auth: authWithLimit,
startClient: false,
accountId: opts.accountId ?? undefined,
accountId: auth.accountId,
});
setActiveMatrixClient(client, opts.accountId);
setActiveMatrixClient(client, auth.accountId);
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
@@ -339,7 +346,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
await resolveSharedMatrixClient({
cfg,
auth: authWithLimit,
accountId: opts.accountId,
accountId: auth.accountId,
});
logVerboseMessage("matrix: client started");
const threadBindingManager = await createMatrixThreadBindingManager({
@@ -398,7 +405,6 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
client,
auth,
accountConfig,
accountId: account.accountId,
env: process.env,
});
if (startupVerification.kind === "verified") {
@@ -440,7 +446,6 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const legacyCryptoRestore = await maybeRestoreLegacyMatrixBackup({
client,
auth,
accountId: account.accountId,
env: process.env,
});
if (legacyCryptoRestore.kind === "restored") {
@@ -474,9 +479,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
try {
threadBindingManager.stop();
logVerboseMessage("matrix: stopping client");
stopSharedClientForAccount(auth, opts.accountId);
stopSharedClientForAccount(auth);
} finally {
setActiveMatrixClient(null, opts.accountId);
setActiveMatrixClient(null, auth.accountId);
resolve();
}
};

View File

@@ -27,6 +27,7 @@ describe("maybeRestoreLegacyMatrixBackup", () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
const auth = {
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
@@ -89,6 +90,7 @@ describe("maybeRestoreLegacyMatrixBackup", () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
const auth = {
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",

View File

@@ -45,8 +45,7 @@ function isMigrationState(value: unknown): value is MatrixLegacyCryptoMigrationS
export async function maybeRestoreLegacyMatrixBackup(params: {
client: Pick<MatrixClient, "restoreRoomKeyBackup">;
auth: Pick<MatrixAuth, "homeserver" | "userId" | "accessToken">;
accountId?: string | null;
auth: Pick<MatrixAuth, "homeserver" | "userId" | "accessToken" | "accountId">;
env?: NodeJS.ProcessEnv;
stateDir?: string;
}): Promise<MatrixLegacyCryptoRestoreResult> {
@@ -57,7 +56,7 @@ export async function maybeRestoreLegacyMatrixBackup(params: {
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
accountId: params.accountId,
accountId: params.auth.accountId,
});
const statePath = path.join(rootDir, "legacy-crypto-migration.json");
const { value } = await readJsonFileWithFallback<MatrixLegacyCryptoMigrationState | null>(

View File

@@ -12,6 +12,16 @@ function createStateFilePath(rootDir: string): string {
return path.join(rootDir, "startup-verification.json");
}
function createAuth(accountId = "default") {
return {
accountId,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
};
}
type VerificationSummaryLike = {
id: string;
transactionId?: string;
@@ -80,12 +90,7 @@ describe("ensureMatrixStartupVerification", () => {
const result = await ensureMatrixStartupVerification({
client: harness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
auth: createAuth(),
accountConfig: {},
stateFilePath: createStateFilePath(tempHome),
});
@@ -105,12 +110,7 @@ describe("ensureMatrixStartupVerification", () => {
const result = await ensureMatrixStartupVerification({
client: harness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
auth: createAuth(),
accountConfig: {},
stateFilePath: createStateFilePath(tempHome),
});
@@ -135,12 +135,7 @@ describe("ensureMatrixStartupVerification", () => {
const result = await ensureMatrixStartupVerification({
client: harness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
auth: createAuth(),
accountConfig: {},
stateFilePath: createStateFilePath(tempHome),
});
@@ -156,12 +151,7 @@ describe("ensureMatrixStartupVerification", () => {
const harness = createHarness();
await ensureMatrixStartupVerification({
client: harness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
auth: createAuth(),
accountConfig: {},
stateFilePath: createStateFilePath(tempHome),
nowMs: Date.now(),
@@ -170,12 +160,7 @@ describe("ensureMatrixStartupVerification", () => {
const second = await ensureMatrixStartupVerification({
client: harness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
auth: createAuth(),
accountConfig: {},
stateFilePath: createStateFilePath(tempHome),
nowMs: Date.now() + 60_000,
@@ -193,12 +178,7 @@ describe("ensureMatrixStartupVerification", () => {
const result = await ensureMatrixStartupVerification({
client: harness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
auth: createAuth(),
accountConfig: {
startupVerification: "off",
},
@@ -216,12 +196,7 @@ describe("ensureMatrixStartupVerification", () => {
const result = await ensureMatrixStartupVerification({
client: harness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
auth: createAuth(),
accountConfig: {},
stateFilePath: createStateFilePath(tempHome),
nowMs: Date.parse("2026-03-08T12:00:00.000Z"),
@@ -243,12 +218,7 @@ describe("ensureMatrixStartupVerification", () => {
const result = await ensureMatrixStartupVerification({
client: harness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
auth: createAuth(),
accountConfig: {},
stateFilePath: createStateFilePath(tempHome),
});
@@ -261,12 +231,7 @@ describe("ensureMatrixStartupVerification", () => {
const cooledDown = await ensureMatrixStartupVerification({
client: harness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
auth: createAuth(),
accountConfig: {},
stateFilePath: createStateFilePath(tempHome),
nowMs: Date.now() + 60_000,
@@ -286,12 +251,7 @@ describe("ensureMatrixStartupVerification", () => {
await ensureMatrixStartupVerification({
client: failingHarness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
auth: createAuth(),
accountConfig: {},
stateFilePath,
nowMs: Date.parse("2026-03-08T12:00:00.000Z"),
@@ -300,12 +260,7 @@ describe("ensureMatrixStartupVerification", () => {
const retryingHarness = createHarness();
const result = await ensureMatrixStartupVerification({
client: retryingHarness.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
auth: createAuth(),
accountConfig: {},
stateFilePath,
nowMs: Date.parse("2026-03-08T13:30:00.000Z"),
@@ -322,12 +277,7 @@ describe("ensureMatrixStartupVerification", () => {
await ensureMatrixStartupVerification({
client: unverified.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
auth: createAuth(),
accountConfig: {},
stateFilePath,
nowMs: Date.parse("2026-03-08T12:00:00.000Z"),
@@ -338,12 +288,7 @@ describe("ensureMatrixStartupVerification", () => {
const verified = createHarness({ verified: true });
const result = await ensureMatrixStartupVerification({
client: verified.client as never,
auth: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
},
auth: createAuth(),
accountConfig: {},
stateFilePath,
});

View File

@@ -44,14 +44,13 @@ function normalizeCooldownHours(value: number | undefined): number {
function resolveStartupVerificationStatePath(params: {
auth: MatrixAuth;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
}): string {
const storagePaths = resolveMatrixStoragePaths({
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
accountId: params.accountId,
accountId: params.auth.accountId,
env: params.env,
});
return path.join(storagePaths.rootDir, STARTUP_VERIFICATION_STATE_FILENAME);
@@ -143,7 +142,6 @@ export async function ensureMatrixStartupVerification(params: {
client: Pick<MatrixClient, "crypto" | "getOwnDeviceVerificationStatus">;
auth: MatrixAuth;
accountConfig: Pick<MatrixConfig, "startupVerification" | "startupVerificationCooldownHours">;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
nowMs?: number;
stateFilePath?: string;
@@ -157,7 +155,6 @@ export async function ensureMatrixStartupVerification(params: {
params.stateFilePath ??
resolveStartupVerificationStatePath({
auth: params.auth,
accountId: params.accountId,
env: params.env,
});

View File

@@ -0,0 +1,110 @@
import type { MatrixMessageSummary } from "./actions/types.js";
import {
buildPollResultsSummary,
formatPollAsText,
formatPollResultsAsText,
isPollEventType,
isPollStartType,
parsePollStartContent,
resolvePollReferenceEventId,
type PollStartContent,
} from "./poll-types.js";
import type { MatrixClient, MatrixRawEvent } from "./sdk.js";
export type MatrixPollSnapshot = {
pollEventId: string;
triggerEvent: MatrixRawEvent;
rootEvent: MatrixRawEvent;
text: string;
};
export function resolveMatrixPollRootEventId(
event: Pick<MatrixRawEvent, "event_id" | "type" | "content">,
): string | null {
if (isPollStartType(event.type)) {
const eventId = event.event_id?.trim();
return eventId ? eventId : null;
}
return resolvePollReferenceEventId(event.content);
}
async function readAllPollRelations(
client: MatrixClient,
roomId: string,
pollEventId: string,
): Promise<MatrixRawEvent[]> {
const relationEvents: MatrixRawEvent[] = [];
let nextBatch: string | undefined;
do {
const page = await client.getRelations(roomId, pollEventId, "m.reference", undefined, {
from: nextBatch,
});
relationEvents.push(...page.events);
nextBatch = page.nextBatch ?? undefined;
} while (nextBatch);
return relationEvents;
}
export async function fetchMatrixPollSnapshot(
client: MatrixClient,
roomId: string,
event: MatrixRawEvent,
): Promise<MatrixPollSnapshot | null> {
if (!isPollEventType(event.type)) {
return null;
}
const pollEventId = resolveMatrixPollRootEventId(event);
if (!pollEventId) {
return null;
}
const rootEvent = isPollStartType(event.type)
? event
: ((await client.getEvent(roomId, pollEventId)) as MatrixRawEvent);
if (!isPollStartType(rootEvent.type)) {
return null;
}
const pollStartContent = rootEvent.content as PollStartContent;
const pollSummary = parsePollStartContent(pollStartContent);
if (!pollSummary) {
return null;
}
const relationEvents = await readAllPollRelations(client, roomId, pollEventId);
const pollResults = buildPollResultsSummary({
pollEventId,
roomId,
sender: rootEvent.sender,
senderName: rootEvent.sender,
content: pollStartContent,
relationEvents,
});
return {
pollEventId,
triggerEvent: event,
rootEvent,
text: pollResults ? formatPollResultsAsText(pollResults) : formatPollAsText(pollSummary),
};
}
export async function fetchMatrixPollMessageSummary(
client: MatrixClient,
roomId: string,
event: MatrixRawEvent,
): Promise<MatrixMessageSummary | null> {
const snapshot = await fetchMatrixPollSnapshot(client, roomId, event);
if (!snapshot) {
return null;
}
return {
eventId: snapshot.pollEventId,
sender: snapshot.rootEvent.sender,
body: snapshot.text,
msgtype: "m.text",
timestamp: snapshot.triggerEvent.origin_server_ts || snapshot.rootEvent.origin_server_ts,
};
}

View File

@@ -322,7 +322,9 @@ export function buildPollResultsSummary(params: {
});
}
const voteCounts = new Map(parsed.answers.map((answer) => [answer.id, 0] as const));
const voteCounts = new Map<string, number>(
parsed.answers.map((answer): [string, number] => [answer.id, 0]),
);
let totalVotes = 0;
for (const latestVote of latestVoteBySender.values()) {
if (latestVote.answerIds.length === 0) {

View File

@@ -50,4 +50,22 @@ describe("probeMatrix", () => {
localTimeoutMs: 500,
});
});
it("passes accountId through to client creation", async () => {
await probeMatrix({
homeserver: "https://matrix.example.org",
accessToken: "tok",
userId: "@bot:example.org",
timeoutMs: 500,
accountId: "ops",
});
expect(createMatrixClientMock).toHaveBeenCalledWith({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
localTimeoutMs: 500,
accountId: "ops",
});
});
});

View File

@@ -12,6 +12,7 @@ export async function probeMatrix(params: {
accessToken: string;
userId?: string;
timeoutMs: number;
accountId?: string | null;
}): Promise<MatrixProbe> {
const started = Date.now();
const result: MatrixProbe = {
@@ -48,6 +49,7 @@ export async function probeMatrix(params: {
userId: inputUserId,
accessToken: params.accessToken,
localTimeoutMs: params.timeoutMs,
accountId: params.accountId,
});
// The client wrapper resolves user ID via whoami when needed.
const userId = await client.getUserId();

View File

@@ -579,6 +579,25 @@ export class MatrixClient {
};
}
async hydrateEvents(
roomId: string,
events: Array<Record<string, unknown>>,
): Promise<MatrixRawEvent[]> {
if (events.length === 0) {
return [];
}
const mapper = this.client.getEventMapper();
const mappedEvents = events.map((event) =>
mapper({
room_id: roomId,
...event,
}),
);
await Promise.all(mappedEvents.map((event) => this.client.decryptEventIfNeeded(event)));
return mappedEvents.map((event) => matrixEventToRaw(event));
}
async setTyping(roomId: string, typing: boolean, timeoutMs: number): Promise<void> {
await this.client.sendTyping(roomId, typing, timeoutMs);
}

View File

@@ -5,6 +5,8 @@ const getActiveMatrixClientMock = vi.fn();
const createMatrixClientMock = vi.fn();
const isBunRuntimeMock = vi.fn(() => false);
const resolveMatrixAuthMock = vi.fn();
const resolveMatrixAuthContextMock = vi.fn();
const getMatrixRuntimeMock = vi.fn();
vi.mock("../active-client.js", () => ({
getActiveMatrixClient: (...args: unknown[]) => getActiveMatrixClientMock(...args),
@@ -14,6 +16,11 @@ vi.mock("../client.js", () => ({
createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args),
isBunRuntime: () => isBunRuntimeMock(),
resolveMatrixAuth: (...args: unknown[]) => resolveMatrixAuthMock(...args),
resolveMatrixAuthContext: (...args: unknown[]) => resolveMatrixAuthContextMock(...args),
}));
vi.mock("../../runtime.js", () => ({
getMatrixRuntime: () => getMatrixRuntimeMock(),
}));
let resolveMatrixClient: typeof import("./client.js").resolveMatrixClient;
@@ -30,7 +37,19 @@ describe("resolveMatrixClient", () => {
vi.clearAllMocks();
getActiveMatrixClientMock.mockReturnValue(null);
isBunRuntimeMock.mockReturnValue(false);
getMatrixRuntimeMock.mockReturnValue({
config: {
loadConfig: () => ({}),
},
});
resolveMatrixAuthContextMock.mockReturnValue({
cfg: {},
env: process.env,
accountId: "default",
resolved: {},
});
resolveMatrixAuthMock.mockResolvedValue({
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
@@ -75,4 +94,35 @@ describe("resolveMatrixClient", () => {
expect(resolveMatrixAuthMock).not.toHaveBeenCalled();
expect(createMatrixClientMock).not.toHaveBeenCalled();
});
it("uses the effective account id when auth resolution is implicit", async () => {
resolveMatrixAuthContextMock.mockReturnValue({
cfg: {},
env: process.env,
accountId: "ops",
resolved: {},
});
resolveMatrixAuthMock.mockResolvedValue({
accountId: "ops",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
password: undefined,
deviceId: "DEVICE123",
encryption: false,
});
await resolveMatrixClient({});
expect(getActiveMatrixClientMock).toHaveBeenCalledWith("ops");
expect(resolveMatrixAuthMock).toHaveBeenCalledWith({
cfg: {},
accountId: "ops",
});
expect(createMatrixClientMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "ops",
}),
);
});
});

View File

@@ -2,7 +2,12 @@ import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import { resolveMatrixAccountConfig } from "../accounts.js";
import { getActiveMatrixClient } from "../active-client.js";
import { createMatrixClient, isBunRuntime, resolveMatrixAuth } from "../client.js";
import {
createMatrixClient,
isBunRuntime,
resolveMatrixAuth,
resolveMatrixAuthContext,
} from "../client.js";
import type { MatrixClient } from "../sdk.js";
const getCore = () => getMatrixRuntime();
@@ -32,11 +37,19 @@ export async function resolveMatrixClient(opts: {
if (opts.client) {
return { client: opts.client, stopOnDone: false };
}
const active = getActiveMatrixClient(opts.accountId);
const cfg = getCore().config.loadConfig() as CoreConfig;
const authContext = resolveMatrixAuthContext({
cfg,
accountId: opts.accountId,
});
const active = getActiveMatrixClient(authContext.accountId);
if (active) {
return { client: active, stopOnDone: false };
}
const auth = await resolveMatrixAuth({ accountId: opts.accountId });
const auth = await resolveMatrixAuth({
cfg,
accountId: authContext.accountId,
});
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,
@@ -45,7 +58,7 @@ export async function resolveMatrixClient(opts: {
deviceId: auth.deviceId,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
accountId: opts.accountId,
accountId: auth.accountId,
autoBootstrapCrypto: false,
});
await client.prepareForOneOff();

View File

@@ -47,6 +47,7 @@ describe("matrix thread bindings", () => {
await createMatrixThreadBindingManager({
accountId: "ops",
auth: {
accountId: "ops",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
@@ -87,6 +88,7 @@ describe("matrix thread bindings", () => {
await createMatrixThreadBindingManager({
accountId: "ops",
auth: {
accountId: "ops",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
@@ -137,6 +139,7 @@ describe("matrix thread bindings", () => {
await createMatrixThreadBindingManager({
accountId: "ops",
auth: {
accountId: "ops",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
@@ -182,6 +185,7 @@ describe("matrix thread bindings", () => {
await createMatrixThreadBindingManager({
accountId: "ops",
auth: {
accountId: "ops",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",

View File

@@ -339,6 +339,11 @@ export async function createMatrixThreadBindingManager(params: {
enableSweeper?: boolean;
logVerboseMessage?: (message: string) => void;
}): Promise<MatrixThreadBindingManager> {
if (params.auth.accountId !== params.accountId) {
throw new Error(
`Matrix thread binding account mismatch: requested ${params.accountId}, auth resolved ${params.auth.accountId}`,
);
}
const existing = MANAGERS_BY_ACCOUNT_ID.get(params.accountId);
if (existing) {
return existing;

View File

@@ -52,6 +52,7 @@ export async function notifyPairingApproved(params: {
channelId: ChannelId;
id: string;
cfg: OpenClawConfig;
accountId?: string;
runtime?: RuntimeEnv;
/** Extension channels can pass their adapter directly to bypass registry lookup. */
pairingAdapter?: ChannelPairingAdapter;
@@ -64,6 +65,7 @@ export async function notifyPairingApproved(params: {
await adapter.notifyApproval({
cfg: params.cfg,
id: params.id,
accountId: params.accountId,
runtime: params.runtime,
});
}

View File

@@ -268,6 +268,7 @@ export type ChannelPairingAdapter = {
notifyApproval?: (params: {
cfg: OpenClawConfig;
id: string;
accountId?: string;
runtime?: RuntimeEnv;
}) => Promise<void>;
};

View File

@@ -220,6 +220,28 @@ describe("pairing cli", () => {
});
});
it("forwards --account to approval notifications", async () => {
mockApprovedPairing();
await runPairing([
"pairing",
"approve",
"--channel",
"telegram",
"--account",
"yy",
"--notify",
"ABCDEFGH",
]);
expect(notifyPairingApproved).toHaveBeenCalledWith({
channelId: "telegram",
id: "123",
cfg: {},
accountId: "yy",
});
});
it("defaults approve to the sole available channel when only code is provided", async () => {
listPairingChannels.mockReturnValueOnce(["slack"]);
mockApprovedPairing();

View File

@@ -44,9 +44,9 @@ function parseChannel(raw: unknown, channels: PairingChannel[]): PairingChannel
throw new Error(`Invalid channel: ${value}`);
}
async function notifyApproved(channel: PairingChannel, id: string) {
async function notifyApproved(channel: PairingChannel, id: string, accountId?: string) {
const cfg = loadConfig();
await notifyPairingApproved({ channelId: channel, id, cfg });
await notifyPairingApproved({ channelId: channel, id, cfg, accountId });
}
export function registerPairingCli(program: Command) {
@@ -166,7 +166,7 @@ export function registerPairingCli(program: Command) {
if (!opts.notify) {
return;
}
await notifyApproved(channel, approved.id).catch((err) => {
await notifyApproved(channel, approved.id, accountId || undefined).catch((err) => {
defaultRuntime.log(theme.warn(`Failed to notify requester: ${String(err)}`));
});
});