diff --git a/extensions/matrix/api.ts b/extensions/matrix/api.ts index 8f7fe4d268b..620864b9a90 100644 --- a/extensions/matrix/api.ts +++ b/extensions/matrix/api.ts @@ -1,2 +1,3 @@ export * from "./src/setup-core.js"; export * from "./src/setup-surface.js"; +export { matrixOnboardingAdapter as matrixSetupWizard } from "./src/onboarding.js"; diff --git a/extensions/matrix/src/matrix/config-update.test.ts b/extensions/matrix/src/matrix/config-update.test.ts index 515eb82c4d6..0852e703413 100644 --- a/extensions/matrix/src/matrix/config-update.test.ts +++ b/extensions/matrix/src/matrix/config-update.test.ts @@ -47,4 +47,58 @@ describe("updateMatrixAccountConfig", () => { enabled: true, }); }); + + it("updates nested access config for named accounts without touching top-level defaults", () => { + const cfg = { + channels: { + matrix: { + dm: { + policy: "pairing", + }, + groups: { + "!default:example.org": { allow: true }, + }, + accounts: { + ops: { + homeserver: "https://matrix.ops.example.org", + accessToken: "ops-token", + dm: { + enabled: true, + policy: "pairing", + }, + }, + }, + }, + }, + } as CoreConfig; + + const updated = updateMatrixAccountConfig(cfg, "ops", { + dm: { + policy: "allowlist", + allowFrom: ["@alice:example.org"], + }, + groupPolicy: "allowlist", + groups: { + "!ops-room:example.org": { allow: true }, + }, + rooms: null, + }); + + expect(updated.channels?.["matrix"]?.dm?.policy).toBe("pairing"); + expect(updated.channels?.["matrix"]?.groups).toEqual({ + "!default:example.org": { allow: true }, + }); + expect(updated.channels?.["matrix"]?.accounts?.ops).toMatchObject({ + dm: { + enabled: true, + policy: "allowlist", + allowFrom: ["@alice:example.org"], + }, + groupPolicy: "allowlist", + groups: { + "!ops-room:example.org": { allow: true }, + }, + }); + expect(updated.channels?.["matrix"]?.accounts?.ops?.rooms).toBeUndefined(); + }); }); diff --git a/extensions/matrix/src/matrix/config-update.ts b/extensions/matrix/src/matrix/config-update.ts index 3c17e4332df..06bd6f87fe4 100644 --- a/extensions/matrix/src/matrix/config-update.ts +++ b/extensions/matrix/src/matrix/config-update.ts @@ -13,6 +13,11 @@ export type MatrixAccountPatch = { avatarUrl?: string | null; encryption?: boolean | null; initialSyncLimit?: number | null; + dm?: MatrixConfig["dm"] | null; + groupPolicy?: MatrixConfig["groupPolicy"] | null; + groupAllowFrom?: MatrixConfig["groupAllowFrom"] | null; + groups?: MatrixConfig["groups"] | null; + rooms?: MatrixConfig["rooms"] | null; }; function applyNullableStringField( @@ -35,6 +40,42 @@ function applyNullableStringField( target[key] = trimmed; } +function cloneMatrixDmConfig(dm: MatrixConfig["dm"]): MatrixConfig["dm"] { + if (!dm) { + return dm; + } + return { + ...dm, + ...(dm.allowFrom ? { allowFrom: [...dm.allowFrom] } : {}), + }; +} + +function cloneMatrixRoomMap( + rooms: MatrixConfig["groups"] | MatrixConfig["rooms"], +): MatrixConfig["groups"] | MatrixConfig["rooms"] { + if (!rooms) { + return rooms; + } + return Object.fromEntries( + Object.entries(rooms).map(([roomId, roomCfg]) => [roomId, roomCfg ? { ...roomCfg } : roomCfg]), + ); +} + +function applyNullableArrayField( + target: Record, + key: keyof MatrixAccountPatch, + value: Array | null | undefined, +): void { + if (value === undefined) { + return; + } + if (value === null) { + delete target[key]; + return; + } + target[key] = [...value]; +} + export function shouldStoreMatrixAccountAtTopLevel(cfg: CoreConfig, accountId: string): boolean { const normalizedAccountId = normalizeAccountId(accountId); if (normalizedAccountId !== DEFAULT_ACCOUNT_ID) { @@ -103,6 +144,38 @@ export function updateMatrixAccountConfig( nextAccount.encryption = patch.encryption; } } + if (patch.dm !== undefined) { + if (patch.dm === null) { + delete nextAccount.dm; + } else { + nextAccount.dm = cloneMatrixDmConfig({ + ...((nextAccount.dm as MatrixConfig["dm"] | undefined) ?? {}), + ...patch.dm, + }); + } + } + if (patch.groupPolicy !== undefined) { + if (patch.groupPolicy === null) { + delete nextAccount.groupPolicy; + } else { + nextAccount.groupPolicy = patch.groupPolicy; + } + } + applyNullableArrayField(nextAccount, "groupAllowFrom", patch.groupAllowFrom); + if (patch.groups !== undefined) { + if (patch.groups === null) { + delete nextAccount.groups; + } else { + nextAccount.groups = cloneMatrixRoomMap(patch.groups); + } + } + if (patch.rooms !== undefined) { + if (patch.rooms === null) { + delete nextAccount.rooms; + } else { + nextAccount.rooms = cloneMatrixRoomMap(patch.rooms); + } + } if (shouldStoreMatrixAccountAtTopLevel(cfg, normalizedAccountId)) { const { accounts: _ignoredAccounts, defaultAccount, ...baseMatrix } = matrix; diff --git a/extensions/matrix/src/onboarding.test.ts b/extensions/matrix/src/onboarding.test.ts index 6a809616027..a4ce2e55f1a 100644 --- a/extensions/matrix/src/onboarding.test.ts +++ b/extensions/matrix/src/onboarding.test.ts @@ -160,4 +160,107 @@ describe("matrix onboarding", () => { expect(noteText).toContain("MATRIX__DEVICE_ID"); expect(noteText).toContain("MATRIX__DEVICE_NAME"); }); + + it("writes allowlists and room access to the selected Matrix account", async () => { + setMatrixRuntime({ + state: { + resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) => + (homeDir ?? (() => "/tmp"))(), + }, + config: { + loadConfig: () => ({}), + }, + } as never); + + const prompter = { + note: vi.fn(async () => {}), + select: vi.fn(async ({ message }: { message: string }) => { + if (message === "Matrix already configured. What do you want to do?") { + return "add-account"; + } + if (message === "Matrix auth method") { + return "token"; + } + if (message === "Matrix rooms access") { + return "allowlist"; + } + throw new Error(`unexpected select prompt: ${message}`); + }), + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Matrix account name") { + return "ops"; + } + if (message === "Matrix homeserver URL") { + return "https://matrix.ops.example.org"; + } + if (message === "Matrix access token") { + return "ops-token"; + } + if (message === "Matrix device name (optional)") { + return "Ops Gateway"; + } + if (message === "Matrix allowFrom (full @user:server; display name only if unique)") { + return "@alice:example.org"; + } + if (message === "Matrix rooms allowlist (comma-separated)") { + return "!ops-room:example.org"; + } + throw new Error(`unexpected text prompt: ${message}`); + }), + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Enable end-to-end encryption (E2EE)?") { + return false; + } + if (message === "Configure Matrix rooms access?") { + return true; + } + return false; + }), + } as unknown as WizardPrompter; + + const result = await matrixOnboardingAdapter.configureInteractive!({ + cfg: { + channels: { + matrix: { + accounts: { + default: { + homeserver: "https://matrix.main.example.org", + accessToken: "main-token", + }, + }, + }, + }, + } as CoreConfig, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv, + prompter, + options: undefined, + accountOverrides: {}, + shouldPromptAccountIds: true, + forceAllowFrom: true, + configured: true, + label: "Matrix", + }); + + expect(result).not.toBe("skip"); + if (result === "skip") { + return; + } + + expect(result.accountId).toBe("ops"); + expect(result.cfg.channels?.["matrix"]?.accounts?.ops).toMatchObject({ + homeserver: "https://matrix.ops.example.org", + accessToken: "ops-token", + deviceName: "Ops Gateway", + dm: { + policy: "allowlist", + allowFrom: ["@alice:example.org"], + }, + groupPolicy: "allowlist", + groups: { + "!ops-room:example.org": { allow: true }, + }, + }); + expect(result.cfg.channels?.["matrix"]?.dm).toBeUndefined(); + expect(result.cfg.channels?.["matrix"]?.groups).toBeUndefined(); + }); }); diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts index de9ebe9c39c..b49b3be4973 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/onboarding.ts @@ -1 +1,561 @@ -export { matrixOnboardingAdapter } from "./setup-surface.js"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; +import type { DmPolicy } from "openclaw/plugin-sdk/matrix"; +import { + addWildcardAllowFrom, + formatDocsLink, + mergeAllowFromEntries, + normalizeAccountId, + promptAccountId, + type RuntimeEnv, + type WizardPrompter, +} from "openclaw/plugin-sdk/matrix"; +import { + promptChannelAccessConfig, + type ChannelSetupDmPolicy, + type ChannelSetupWizardAdapter, +} from "openclaw/plugin-sdk/setup"; +import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; +import { + listMatrixAccountIds, + resolveDefaultMatrixAccountId, + resolveMatrixAccount, + resolveMatrixAccountConfig, +} from "./matrix/accounts.js"; +import { + getMatrixScopedEnvVarNames, + hasReadyMatrixEnvAuth, + resolveScopedMatrixEnvConfig, +} from "./matrix/client.js"; +import { updateMatrixAccountConfig } from "./matrix/config-update.js"; +import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js"; +import { resolveMatrixTargets } from "./resolve-targets.js"; +import type { CoreConfig } from "./types.js"; + +const channel = "matrix" as const; + +function resolveMatrixOnboardingAccountId(cfg: CoreConfig, accountId?: string): string { + return normalizeAccountId( + accountId?.trim() || resolveDefaultMatrixAccountId(cfg) || DEFAULT_ACCOUNT_ID, + ); +} + +function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy, accountId?: string) { + const resolvedAccountId = resolveMatrixOnboardingAccountId(cfg, accountId); + const existing = resolveMatrixAccountConfig({ + cfg, + accountId: resolvedAccountId, + }); + const allowFrom = policy === "open" ? addWildcardAllowFrom(existing.dm?.allowFrom) : undefined; + return updateMatrixAccountConfig(cfg, resolvedAccountId, { + dm: { + ...existing.dm, + policy, + ...(allowFrom ? { allowFrom } : {}), + }, + }); +} + +async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "Matrix requires a homeserver URL.", + "Use an access token (recommended) or password login to an existing account.", + "With access token: user ID is fetched automatically.", + "Env vars supported: MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_ACCESS_TOKEN, MATRIX_PASSWORD, MATRIX_DEVICE_ID, MATRIX_DEVICE_NAME.", + "Per-account env vars: MATRIX__HOMESERVER, MATRIX__USER_ID, MATRIX__ACCESS_TOKEN, MATRIX__PASSWORD, MATRIX__DEVICE_ID, MATRIX__DEVICE_NAME.", + `Docs: ${formatDocsLink("/channels/matrix", "channels/matrix")}`, + ].join("\n"), + "Matrix setup", + ); +} + +async function promptMatrixAllowFrom(params: { + cfg: CoreConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + const { cfg, prompter } = params; + const accountId = resolveMatrixOnboardingAccountId(cfg, params.accountId); + const existingConfig = resolveMatrixAccountConfig({ cfg, accountId }); + const existingAllowFrom = existingConfig.dm?.allowFrom ?? []; + const account = resolveMatrixAccount({ cfg, accountId }); + const canResolve = Boolean(account.configured); + + const parseInput = (raw: string) => + raw + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); + + const isFullUserId = (value: string) => value.startsWith("@") && value.includes(":"); + + while (true) { + const entry = await prompter.text({ + message: "Matrix allowFrom (full @user:server; display name only if unique)", + placeholder: "@user:server", + initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + const parts = parseInput(String(entry)); + const resolvedIds: string[] = []; + const pending: string[] = []; + const unresolved: string[] = []; + const unresolvedNotes: string[] = []; + + for (const part of parts) { + if (isFullUserId(part)) { + resolvedIds.push(part); + continue; + } + if (!canResolve) { + unresolved.push(part); + continue; + } + pending.push(part); + } + + if (pending.length > 0) { + const results = await resolveMatrixTargets({ + cfg, + inputs: pending, + kind: "user", + }).catch(() => []); + for (const result of results) { + if (result?.resolved && result.id) { + resolvedIds.push(result.id); + continue; + } + if (result?.input) { + unresolved.push(result.input); + if (result.note) { + unresolvedNotes.push(`${result.input}: ${result.note}`); + } + } + } + } + + if (unresolved.length > 0) { + const details = unresolvedNotes.length > 0 ? unresolvedNotes : unresolved; + await prompter.note( + `Could not resolve:\n${details.join("\n")}\nUse full @user:server IDs.`, + "Matrix allowlist", + ); + continue; + } + + const unique = mergeAllowFromEntries(existingAllowFrom, resolvedIds); + return updateMatrixAccountConfig(cfg, accountId, { + dm: { + ...existingConfig.dm, + policy: "allowlist", + allowFrom: unique, + }, + }); + } +} + +function setMatrixGroupPolicy( + cfg: CoreConfig, + groupPolicy: "open" | "allowlist" | "disabled", + accountId?: string, +) { + return updateMatrixAccountConfig(cfg, resolveMatrixOnboardingAccountId(cfg, accountId), { + groupPolicy, + }); +} + +function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[], accountId?: string) { + const groups = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }])); + return updateMatrixAccountConfig(cfg, resolveMatrixOnboardingAccountId(cfg, accountId), { + groups, + rooms: null, + }); +} + +const dmPolicy: ChannelSetupDmPolicy = { + label: "Matrix", + channel, + policyKey: "channels.matrix.dm.policy", + allowFromKey: "channels.matrix.dm.allowFrom", + getCurrent: (cfg, accountId) => + resolveMatrixAccountConfig({ + cfg: cfg as CoreConfig, + accountId: resolveMatrixOnboardingAccountId(cfg as CoreConfig, accountId), + }).dm?.policy ?? "pairing", + setPolicy: (cfg, policy, accountId) => setMatrixDmPolicy(cfg as CoreConfig, policy, accountId), + promptAllowFrom: promptMatrixAllowFrom, +}; + +type MatrixConfigureIntent = "update" | "add-account"; + +async function runMatrixConfigure(params: { + cfg: CoreConfig; + runtime: RuntimeEnv; + prompter: WizardPrompter; + forceAllowFrom: boolean; + accountOverrides?: Partial>; + shouldPromptAccountIds?: boolean; + intent: MatrixConfigureIntent; +}): Promise<{ cfg: CoreConfig; accountId: string }> { + let next = params.cfg; + await ensureMatrixSdkInstalled({ + runtime: params.runtime, + confirm: async (message) => + await params.prompter.confirm({ + message, + initialValue: true, + }), + }); + const defaultAccountId = resolveDefaultMatrixAccountId(next); + let accountId = defaultAccountId || DEFAULT_ACCOUNT_ID; + if (params.intent === "add-account") { + const enteredName = String( + await params.prompter.text({ + message: "Matrix account name", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + accountId = normalizeAccountId(enteredName); + if (enteredName !== accountId) { + await params.prompter.note(`Account id will be "${accountId}".`, "Matrix account"); + } + next = updateMatrixAccountConfig(next, accountId, { name: enteredName, enabled: true }); + } else { + const override = params.accountOverrides?.[channel]?.trim(); + if (override) { + accountId = normalizeAccountId(override); + } else if (params.shouldPromptAccountIds) { + accountId = await promptAccountId({ + cfg: next, + prompter: params.prompter, + label: "Matrix", + currentId: accountId, + listAccountIds: (inputCfg) => listMatrixAccountIds(inputCfg as CoreConfig), + defaultAccountId, + }); + } + } + + const existing = resolveMatrixAccountConfig({ cfg: next, accountId }); + const account = resolveMatrixAccount({ cfg: next, accountId }); + if (!account.configured) { + await noteMatrixAuthHelp(params.prompter); + } + + const scopedEnv = resolveScopedMatrixEnvConfig(accountId, process.env); + const defaultScopedEnv = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, process.env); + const globalEnv = { + homeserver: process.env.MATRIX_HOMESERVER?.trim() ?? "", + userId: process.env.MATRIX_USER_ID?.trim() ?? "", + accessToken: process.env.MATRIX_ACCESS_TOKEN?.trim() || undefined, + password: process.env.MATRIX_PASSWORD?.trim() || undefined, + }; + const scopedReady = hasReadyMatrixEnvAuth(scopedEnv); + const defaultScopedReady = hasReadyMatrixEnvAuth(defaultScopedEnv); + const globalReady = hasReadyMatrixEnvAuth(globalEnv); + const envReady = + scopedReady || (accountId === DEFAULT_ACCOUNT_ID && (defaultScopedReady || globalReady)); + const envHomeserver = + scopedEnv.homeserver || + (accountId === DEFAULT_ACCOUNT_ID + ? defaultScopedEnv.homeserver || globalEnv.homeserver + : undefined); + const envUserId = + scopedEnv.userId || + (accountId === DEFAULT_ACCOUNT_ID ? defaultScopedEnv.userId || globalEnv.userId : undefined); + + if ( + envReady && + !existing.homeserver && + !existing.userId && + !existing.accessToken && + !existing.password + ) { + const scopedEnvNames = getMatrixScopedEnvVarNames(accountId); + const envSourceHint = + accountId === DEFAULT_ACCOUNT_ID + ? "MATRIX_* or MATRIX_DEFAULT_*" + : `${scopedEnvNames.homeserver} (+ auth vars)`; + const useEnv = await params.prompter.confirm({ + message: `Matrix env vars detected (${envSourceHint}). Use env values?`, + initialValue: true, + }); + if (useEnv) { + next = updateMatrixAccountConfig(next, accountId, { enabled: true }); + if (params.forceAllowFrom) { + next = await promptMatrixAllowFrom({ + cfg: next, + prompter: params.prompter, + accountId, + }); + } + return { cfg: next, accountId }; + } + } + + const homeserver = String( + await params.prompter.text({ + message: "Matrix homeserver URL", + initialValue: existing.homeserver ?? envHomeserver, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + if (!/^https?:\/\//i.test(raw)) { + return "Use a full URL (https://...)"; + } + return undefined; + }, + }), + ).trim(); + + let accessToken = existing.accessToken ?? ""; + let password = typeof existing.password === "string" ? existing.password : ""; + let userId = existing.userId ?? ""; + + if (accessToken || password) { + const keep = await params.prompter.confirm({ + message: "Matrix credentials already configured. Keep them?", + initialValue: true, + }); + if (!keep) { + accessToken = ""; + password = ""; + userId = ""; + } + } + + if (!accessToken && !password) { + const authMode = await params.prompter.select({ + message: "Matrix auth method", + options: [ + { value: "token", label: "Access token (user ID fetched automatically)" }, + { value: "password", label: "Password (requires user ID)" }, + ], + }); + + if (authMode === "token") { + accessToken = String( + await params.prompter.text({ + message: "Matrix access token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + userId = ""; + } else { + userId = String( + await params.prompter.text({ + message: "Matrix user ID", + initialValue: existing.userId ?? envUserId, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + if (!raw.startsWith("@")) { + return "Matrix user IDs should start with @"; + } + if (!raw.includes(":")) { + return "Matrix user IDs should include a server (:server)"; + } + return undefined; + }, + }), + ).trim(); + password = String( + await params.prompter.text({ + message: "Matrix password", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } + + const deviceName = String( + await params.prompter.text({ + message: "Matrix device name (optional)", + initialValue: existing.deviceName ?? "OpenClaw Gateway", + }), + ).trim(); + + const enableEncryption = await params.prompter.confirm({ + message: "Enable end-to-end encryption (E2EE)?", + initialValue: existing.encryption ?? false, + }); + + next = updateMatrixAccountConfig(next, accountId, { + enabled: true, + homeserver, + userId: userId || null, + accessToken: accessToken || null, + password: password || null, + deviceName: deviceName || null, + encryption: enableEncryption, + }); + + if (params.forceAllowFrom) { + next = await promptMatrixAllowFrom({ + cfg: next, + prompter: params.prompter, + accountId, + }); + } + + const existingAccountConfig = resolveMatrixAccountConfig({ cfg: next, accountId }); + const existingGroups = existingAccountConfig.groups ?? existingAccountConfig.rooms; + const accessConfig = await promptChannelAccessConfig({ + prompter: params.prompter, + label: "Matrix rooms", + currentPolicy: existingAccountConfig.groupPolicy ?? "allowlist", + currentEntries: Object.keys(existingGroups ?? {}), + placeholder: "!roomId:server, #alias:server, Project Room", + updatePrompt: Boolean(existingGroups), + }); + if (accessConfig) { + if (accessConfig.policy !== "allowlist") { + next = setMatrixGroupPolicy(next, accessConfig.policy, accountId); + } else { + let roomKeys = accessConfig.entries; + if (accessConfig.entries.length > 0) { + try { + const resolvedIds: string[] = []; + const unresolved: string[] = []; + for (const entry of accessConfig.entries) { + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + const cleaned = trimmed.replace(/^(room|channel):/i, "").trim(); + if (cleaned.startsWith("!") && cleaned.includes(":")) { + resolvedIds.push(cleaned); + continue; + } + const matches = await listMatrixDirectoryGroupsLive({ + cfg: next, + accountId, + query: trimmed, + limit: 10, + }); + const exact = matches.find( + (match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(), + ); + const best = exact ?? matches[0]; + if (best?.id) { + resolvedIds.push(best.id); + } else { + unresolved.push(entry); + } + } + roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; + if (resolvedIds.length > 0 || unresolved.length > 0) { + await params.prompter.note( + [ + resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined, + unresolved.length > 0 + ? `Unresolved (kept as typed): ${unresolved.join(", ")}` + : undefined, + ] + .filter(Boolean) + .join("\n"), + "Matrix rooms", + ); + } + } catch (err) { + await params.prompter.note( + `Room lookup failed; keeping entries as typed. ${String(err)}`, + "Matrix rooms", + ); + } + } + next = setMatrixGroupPolicy(next, "allowlist", accountId); + next = setMatrixGroupRooms(next, roomKeys, accountId); + } + } + + return { cfg: next, accountId }; +} + +export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = { + channel, + getStatus: async ({ cfg }) => { + const account = resolveMatrixAccount({ cfg: cfg as CoreConfig }); + const configured = account.configured; + const sdkReady = isMatrixSdkAvailable(); + return { + channel, + configured, + statusLines: [ + `Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`, + ], + selectionHint: !sdkReady ? "install matrix-js-sdk" : configured ? "configured" : "needs auth", + }; + }, + configure: async ({ + cfg, + runtime, + prompter, + forceAllowFrom, + accountOverrides, + shouldPromptAccountIds, + }) => + await runMatrixConfigure({ + cfg: cfg as CoreConfig, + runtime, + prompter, + forceAllowFrom, + accountOverrides, + shouldPromptAccountIds, + intent: "update", + }), + configureInteractive: async ({ + cfg, + runtime, + prompter, + forceAllowFrom, + accountOverrides, + shouldPromptAccountIds, + configured, + }) => { + if (!configured) { + return await runMatrixConfigure({ + cfg: cfg as CoreConfig, + runtime, + prompter, + forceAllowFrom, + accountOverrides, + shouldPromptAccountIds, + intent: "update", + }); + } + const action = await prompter.select({ + message: "Matrix already configured. What do you want to do?", + options: [ + { value: "update", label: "Modify settings" }, + { value: "add-account", label: "Add account" }, + { value: "skip", label: "Skip (leave as-is)" }, + ], + initialValue: "update", + }); + if (action === "skip") { + return "skip"; + } + return await runMatrixConfigure({ + cfg: cfg as CoreConfig, + runtime, + prompter, + forceAllowFrom, + accountOverrides, + shouldPromptAccountIds, + intent: action === "add-account" ? "add-account" : "update", + }); + }, + dmPolicy, + disable: (cfg) => ({ + ...(cfg as CoreConfig), + channels: { + ...(cfg as CoreConfig).channels, + matrix: { ...(cfg as CoreConfig).channels?.["matrix"], enabled: false }, + }, + }), +}; diff --git a/extensions/matrix/src/setup-surface.ts b/extensions/matrix/src/setup-surface.ts index ebb033a7045..ed601b90400 100644 --- a/extensions/matrix/src/setup-surface.ts +++ b/extensions/matrix/src/setup-surface.ts @@ -1,557 +1 @@ -import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; -import type { DmPolicy } from "openclaw/plugin-sdk/matrix"; -import { - addWildcardAllowFrom, - formatDocsLink, - mergeAllowFromEntries, - normalizeAccountId, - promptAccountId, - promptChannelAccessConfig, - type RuntimeEnv, - type WizardPrompter, -} from "openclaw/plugin-sdk/matrix"; -import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; -import { - listMatrixAccountIds, - resolveDefaultMatrixAccountId, - resolveMatrixAccount, - resolveMatrixAccountConfig, -} from "./matrix/accounts.js"; -import { - getMatrixScopedEnvVarNames, - hasReadyMatrixEnvAuth, - resolveScopedMatrixEnvConfig, -} from "./matrix/client.js"; -import { updateMatrixAccountConfig } from "./matrix/config-update.js"; -import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js"; -import { resolveMatrixTargets } from "./resolve-targets.js"; -import type { CoreConfig } from "./types.js"; - -const channel = "matrix" as const; - -function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) { - const allowFrom = - policy === "open" ? addWildcardAllowFrom(cfg.channels?.["matrix"]?.dm?.allowFrom) : undefined; - return { - ...cfg, - channels: { - ...cfg.channels, - matrix: { - ...cfg.channels?.["matrix"], - dm: { - ...cfg.channels?.["matrix"]?.dm, - policy, - ...(allowFrom ? { allowFrom } : {}), - }, - }, - }, - }; -} - -async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise { - await prompter.note( - [ - "Matrix requires a homeserver URL.", - "Use an access token (recommended) or password login to an existing account.", - "With access token: user ID is fetched automatically.", - "Env vars supported: MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_ACCESS_TOKEN, MATRIX_PASSWORD, MATRIX_DEVICE_ID, MATRIX_DEVICE_NAME.", - "Per-account env vars: MATRIX__HOMESERVER, MATRIX__USER_ID, MATRIX__ACCESS_TOKEN, MATRIX__PASSWORD, MATRIX__DEVICE_ID, MATRIX__DEVICE_NAME.", - `Docs: ${formatDocsLink("/channels/matrix", "channels/matrix")}`, - ].join("\n"), - "Matrix setup", - ); -} - -async function promptMatrixAllowFrom(params: { - cfg: CoreConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - const { cfg, prompter } = params; - const existingAllowFrom = cfg.channels?.["matrix"]?.dm?.allowFrom ?? []; - const account = resolveMatrixAccount({ cfg }); - const canResolve = Boolean(account.configured); - - const parseInput = (raw: string) => - raw - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); - - const isFullUserId = (value: string) => value.startsWith("@") && value.includes(":"); - - while (true) { - const entry = await prompter.text({ - message: "Matrix allowFrom (full @user:server; display name only if unique)", - placeholder: "@user:server", - initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - const parts = parseInput(String(entry)); - const resolvedIds: string[] = []; - const pending: string[] = []; - const unresolved: string[] = []; - const unresolvedNotes: string[] = []; - - for (const part of parts) { - if (isFullUserId(part)) { - resolvedIds.push(part); - continue; - } - if (!canResolve) { - unresolved.push(part); - continue; - } - pending.push(part); - } - - if (pending.length > 0) { - const results = await resolveMatrixTargets({ - cfg, - inputs: pending, - kind: "user", - }).catch(() => []); - for (const result of results) { - if (result?.resolved && result.id) { - resolvedIds.push(result.id); - continue; - } - if (result?.input) { - unresolved.push(result.input); - if (result.note) { - unresolvedNotes.push(`${result.input}: ${result.note}`); - } - } - } - } - - if (unresolved.length > 0) { - const details = unresolvedNotes.length > 0 ? unresolvedNotes : unresolved; - await prompter.note( - `Could not resolve:\n${details.join("\n")}\nUse full @user:server IDs.`, - "Matrix allowlist", - ); - continue; - } - - const unique = mergeAllowFromEntries(existingAllowFrom, resolvedIds); - return { - ...cfg, - channels: { - ...cfg.channels, - matrix: { - ...cfg.channels?.["matrix"], - enabled: true, - dm: { - ...cfg.channels?.["matrix"]?.dm, - policy: "allowlist", - allowFrom: unique, - }, - }, - }, - }; - } -} - -function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") { - return { - ...cfg, - channels: { - ...cfg.channels, - matrix: { - ...cfg.channels?.["matrix"], - enabled: true, - groupPolicy, - }, - }, - }; -} - -function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) { - const groups = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }])); - return { - ...cfg, - channels: { - ...cfg.channels, - matrix: { - ...cfg.channels?.["matrix"], - enabled: true, - groups, - }, - }, - }; -} - -const dmPolicy = { - label: "Matrix", - channel, - policyKey: "channels.matrix.dm.policy", - allowFromKey: "channels.matrix.dm.allowFrom", - getCurrent: (cfg) => (cfg as CoreConfig).channels?.["matrix"]?.dm?.policy ?? "pairing", - setPolicy: (cfg, policy) => setMatrixDmPolicy(cfg as CoreConfig, policy), - promptAllowFrom: promptMatrixAllowFrom, -}; - -type MatrixConfigureIntent = "update" | "add-account"; - -async function runMatrixConfigure(params: { - cfg: CoreConfig; - runtime: RuntimeEnv; - prompter: WizardPrompter; - forceAllowFrom: boolean; - accountOverrides?: Partial>; - shouldPromptAccountIds?: boolean; - intent: MatrixConfigureIntent; -}): Promise<{ cfg: CoreConfig; accountId: string }> { - let next = params.cfg; - await ensureMatrixSdkInstalled({ - runtime: params.runtime, - confirm: async (message) => - await params.prompter.confirm({ - message, - initialValue: true, - }), - }); - const defaultAccountId = resolveDefaultMatrixAccountId(next); - let accountId = defaultAccountId || DEFAULT_ACCOUNT_ID; - if (params.intent === "add-account") { - const enteredName = String( - await params.prompter.text({ - message: "Matrix account name", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - accountId = normalizeAccountId(enteredName); - if (enteredName !== accountId) { - await params.prompter.note(`Account id will be "${accountId}".`, "Matrix account"); - } - next = updateMatrixAccountConfig(next, accountId, { name: enteredName, enabled: true }); - } else { - const override = params.accountOverrides?.[channel]?.trim(); - if (override) { - accountId = normalizeAccountId(override); - } else if (params.shouldPromptAccountIds) { - accountId = await promptAccountId({ - cfg: next, - prompter: params.prompter, - label: "Matrix", - currentId: accountId, - listAccountIds: (inputCfg) => listMatrixAccountIds(inputCfg as CoreConfig), - defaultAccountId, - }); - } - } - - const existing = resolveMatrixAccountConfig({ cfg: next, accountId }); - const account = resolveMatrixAccount({ cfg: next, accountId }); - if (!account.configured) { - await noteMatrixAuthHelp(params.prompter); - } - - const scopedEnv = resolveScopedMatrixEnvConfig(accountId, process.env); - const defaultScopedEnv = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, process.env); - const globalEnv = { - homeserver: process.env.MATRIX_HOMESERVER?.trim() ?? "", - userId: process.env.MATRIX_USER_ID?.trim() ?? "", - accessToken: process.env.MATRIX_ACCESS_TOKEN?.trim() || undefined, - password: process.env.MATRIX_PASSWORD?.trim() || undefined, - }; - const scopedReady = hasReadyMatrixEnvAuth(scopedEnv); - const defaultScopedReady = hasReadyMatrixEnvAuth(defaultScopedEnv); - const globalReady = hasReadyMatrixEnvAuth(globalEnv); - const envReady = - scopedReady || (accountId === DEFAULT_ACCOUNT_ID && (defaultScopedReady || globalReady)); - const envHomeserver = - scopedEnv.homeserver || - (accountId === DEFAULT_ACCOUNT_ID - ? defaultScopedEnv.homeserver || globalEnv.homeserver - : undefined); - const envUserId = - scopedEnv.userId || - (accountId === DEFAULT_ACCOUNT_ID ? defaultScopedEnv.userId || globalEnv.userId : undefined); - - if ( - envReady && - !existing.homeserver && - !existing.userId && - !existing.accessToken && - !existing.password - ) { - const scopedEnvNames = getMatrixScopedEnvVarNames(accountId); - const envSourceHint = - accountId === DEFAULT_ACCOUNT_ID - ? "MATRIX_* or MATRIX_DEFAULT_*" - : `${scopedEnvNames.homeserver} (+ auth vars)`; - const useEnv = await params.prompter.confirm({ - message: `Matrix env vars detected (${envSourceHint}). Use env values?`, - initialValue: true, - }); - if (useEnv) { - next = updateMatrixAccountConfig(next, accountId, { enabled: true }); - if (params.forceAllowFrom) { - next = await promptMatrixAllowFrom({ cfg: next, prompter: params.prompter }); - } - return { cfg: next, accountId }; - } - } - - const homeserver = String( - await params.prompter.text({ - message: "Matrix homeserver URL", - initialValue: existing.homeserver ?? envHomeserver, - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) { - return "Required"; - } - if (!/^https?:\/\//i.test(raw)) { - return "Use a full URL (https://...)"; - } - return undefined; - }, - }), - ).trim(); - - let accessToken = existing.accessToken ?? ""; - let password = typeof existing.password === "string" ? existing.password : ""; - let userId = existing.userId ?? ""; - - if (accessToken || password) { - const keep = await params.prompter.confirm({ - message: "Matrix credentials already configured. Keep them?", - initialValue: true, - }); - if (!keep) { - accessToken = ""; - password = ""; - userId = ""; - } - } - - if (!accessToken && !password) { - const authMode = await params.prompter.select({ - message: "Matrix auth method", - options: [ - { value: "token", label: "Access token (user ID fetched automatically)" }, - { value: "password", label: "Password (requires user ID)" }, - ], - }); - - if (authMode === "token") { - accessToken = String( - await params.prompter.text({ - message: "Matrix access token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - userId = ""; - } else { - userId = String( - await params.prompter.text({ - message: "Matrix user ID", - initialValue: existing.userId ?? envUserId, - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) { - return "Required"; - } - if (!raw.startsWith("@")) { - return "Matrix user IDs should start with @"; - } - if (!raw.includes(":")) { - return "Matrix user IDs should include a server (:server)"; - } - return undefined; - }, - }), - ).trim(); - password = String( - await params.prompter.text({ - message: "Matrix password", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - } - } - - const deviceName = String( - await params.prompter.text({ - message: "Matrix device name (optional)", - initialValue: existing.deviceName ?? "OpenClaw Gateway", - }), - ).trim(); - - const enableEncryption = await params.prompter.confirm({ - message: "Enable end-to-end encryption (E2EE)?", - initialValue: existing.encryption ?? false, - }); - - next = updateMatrixAccountConfig(next, accountId, { - enabled: true, - homeserver, - userId: userId || null, - accessToken: accessToken || null, - password: password || null, - deviceName: deviceName || null, - encryption: enableEncryption, - }); - - if (params.forceAllowFrom) { - next = await promptMatrixAllowFrom({ cfg: next, prompter: params.prompter }); - } - - const existingGroups = next.channels?.["matrix"]?.groups ?? next.channels?.["matrix"]?.rooms; - const accessConfig = await promptChannelAccessConfig({ - prompter: params.prompter, - label: "Matrix rooms", - currentPolicy: next.channels?.["matrix"]?.groupPolicy ?? "allowlist", - currentEntries: Object.keys(existingGroups ?? {}), - placeholder: "!roomId:server, #alias:server, Project Room", - updatePrompt: Boolean(existingGroups), - }); - if (accessConfig) { - if (accessConfig.policy !== "allowlist") { - next = setMatrixGroupPolicy(next, accessConfig.policy); - } else { - let roomKeys = accessConfig.entries; - if (accessConfig.entries.length > 0) { - try { - const resolvedIds: string[] = []; - const unresolved: string[] = []; - for (const entry of accessConfig.entries) { - const trimmed = entry.trim(); - if (!trimmed) { - continue; - } - const cleaned = trimmed.replace(/^(room|channel):/i, "").trim(); - if (cleaned.startsWith("!") && cleaned.includes(":")) { - resolvedIds.push(cleaned); - continue; - } - const matches = await listMatrixDirectoryGroupsLive({ - cfg: next, - query: trimmed, - limit: 10, - }); - const exact = matches.find( - (match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(), - ); - const best = exact ?? matches[0]; - if (best?.id) { - resolvedIds.push(best.id); - } else { - unresolved.push(entry); - } - } - roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; - if (resolvedIds.length > 0 || unresolved.length > 0) { - await params.prompter.note( - [ - resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined, - unresolved.length > 0 - ? `Unresolved (kept as typed): ${unresolved.join(", ")}` - : undefined, - ] - .filter(Boolean) - .join("\n"), - "Matrix rooms", - ); - } - } catch (err) { - await params.prompter.note( - `Room lookup failed; keeping entries as typed. ${String(err)}`, - "Matrix rooms", - ); - } - } - next = setMatrixGroupPolicy(next, "allowlist"); - next = setMatrixGroupRooms(next, roomKeys); - } - } - - return { cfg: next, accountId }; -} - -export const matrixOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const account = resolveMatrixAccount({ cfg: cfg as CoreConfig }); - const configured = account.configured; - const sdkReady = isMatrixSdkAvailable(); - return { - channel, - configured, - statusLines: [ - `Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`, - ], - selectionHint: !sdkReady ? "install matrix-js-sdk" : configured ? "configured" : "needs auth", - }; - }, - configure: async ({ - cfg, - runtime, - prompter, - forceAllowFrom, - accountOverrides, - shouldPromptAccountIds, - }) => - await runMatrixConfigure({ - cfg: cfg as CoreConfig, - runtime, - prompter, - forceAllowFrom, - accountOverrides, - shouldPromptAccountIds, - intent: "update", - }), - configureInteractive: async ({ - cfg, - runtime, - prompter, - forceAllowFrom, - accountOverrides, - shouldPromptAccountIds, - configured, - }) => { - if (!configured) { - return await runMatrixConfigure({ - cfg: cfg as CoreConfig, - runtime, - prompter, - forceAllowFrom, - accountOverrides, - shouldPromptAccountIds, - intent: "update", - }); - } - const action = await prompter.select({ - message: "Matrix already configured. What do you want to do?", - options: [ - { value: "update", label: "Modify settings" }, - { value: "add-account", label: "Add account" }, - { value: "skip", label: "Skip (leave as-is)" }, - ], - initialValue: "update", - }); - if (action === "skip") { - return "skip"; - } - return await runMatrixConfigure({ - cfg: cfg as CoreConfig, - runtime, - prompter, - forceAllowFrom, - accountOverrides, - shouldPromptAccountIds, - intent: action === "add-account" ? "add-account" : "update", - }); - }, - dmPolicy, - disable: (cfg) => ({ - ...(cfg as CoreConfig), - channels: { - ...(cfg as CoreConfig).channels, - matrix: { ...(cfg as CoreConfig).channels?.["matrix"], enabled: false }, - }, - }), -}; +export { matrixOnboardingAdapter } from "./onboarding.js"; diff --git a/src/channels/plugins/setup-wizard-types.ts b/src/channels/plugins/setup-wizard-types.ts index 7dec2ea87a4..47e9fa3ecb2 100644 --- a/src/channels/plugins/setup-wizard-types.ts +++ b/src/channels/plugins/setup-wizard-types.ts @@ -81,8 +81,8 @@ export type ChannelSetupDmPolicy = { channel: ChannelId; policyKey: string; allowFromKey: string; - getCurrent: (cfg: OpenClawConfig) => DmPolicy; - setPolicy: (cfg: OpenClawConfig, policy: DmPolicy) => OpenClawConfig; + getCurrent: (cfg: OpenClawConfig, accountId?: string) => DmPolicy; + setPolicy: (cfg: OpenClawConfig, policy: DmPolicy, accountId?: string) => OpenClawConfig; promptAllowFrom?: (params: { cfg: OpenClawConfig; prompter: WizardPrompter; diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 569e4cd4a44..4b0349ac391 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -292,6 +292,7 @@ async function maybeConfigureDmPolicies(params: { let cfg = params.cfg; const selectPolicy = async (policy: ChannelSetupDmPolicy) => { + const accountId = accountIdsByChannel?.get(policy.channel); await prompter.note( [ "Default: pairing (unknown DMs get a pairing code).", @@ -305,28 +306,31 @@ async function maybeConfigureDmPolicies(params: { ].join("\n"), `${policy.label} DM access`, ); - return (await prompter.select({ - message: `${policy.label} DM policy`, - options: [ - { value: "pairing", label: "Pairing (recommended)" }, - { value: "allowlist", label: "Allowlist (specific users only)" }, - { value: "open", label: "Open (public inbound DMs)" }, - { value: "disabled", label: "Disabled (ignore DMs)" }, - ], - })) as DmPolicy; + return { + accountId, + nextPolicy: (await prompter.select({ + message: `${policy.label} DM policy`, + options: [ + { value: "pairing", label: "Pairing (recommended)" }, + { value: "allowlist", label: "Allowlist (specific users only)" }, + { value: "open", label: "Open (public inbound DMs)" }, + { value: "disabled", label: "Disabled (ignore DMs)" }, + ], + })) as DmPolicy, + }; }; for (const policy of dmPolicies) { - const current = policy.getCurrent(cfg); - const nextPolicy = await selectPolicy(policy); + const { accountId, nextPolicy } = await selectPolicy(policy); + const current = policy.getCurrent(cfg, accountId); if (nextPolicy !== current) { - cfg = policy.setPolicy(cfg, nextPolicy); + cfg = policy.setPolicy(cfg, nextPolicy, accountId); } if (nextPolicy === "allowlist" && policy.promptAllowFrom) { cfg = await policy.promptAllowFrom({ cfg, prompter, - accountId: accountIdsByChannel?.get(policy.channel), + accountId, }); } } diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index b5aeadd67eb..d464cb00c93 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -48,10 +48,10 @@ export { addWildcardAllowFrom, mergeAllowFromEntries, promptAccountId, - promptChannelAccessConfig, promptSingleChannelSecretInput, setTopLevelChannelGroupPolicy, } from "../channels/plugins/setup-wizard-helpers.js"; +export { promptChannelAccessConfig } from "../channels/plugins/setup-group-access.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export { applyAccountNameToChannelSection, diff --git a/src/plugin-sdk/setup.ts b/src/plugin-sdk/setup.ts index 065fbfeed9c..a573b46034c 100644 --- a/src/plugin-sdk/setup.ts +++ b/src/plugin-sdk/setup.ts @@ -6,7 +6,16 @@ export type { SecretInput } from "../config/types.secrets.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; export type { ChannelSetupInput } from "../channels/plugins/types.core.js"; -export type { ChannelSetupDmPolicy } from "../channels/plugins/setup-wizard-types.js"; +export type { + ChannelSetupConfigureContext, + ChannelSetupConfiguredResult, + ChannelSetupDmPolicy, + ChannelSetupInteractiveContext, + ChannelSetupResult, + ChannelSetupStatus, + ChannelSetupStatusContext, + ChannelSetupWizardAdapter, +} from "../channels/plugins/setup-wizard-types.js"; export type { ChannelSetupWizard, ChannelSetupWizardAllowFromEntry, @@ -59,3 +68,4 @@ export { export { createAllowlistSetupWizardProxy } from "../channels/plugins/setup-wizard-proxy.js"; export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; +export { promptChannelAccessConfig } from "../channels/plugins/setup-group-access.js";