diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 360bc706748..fac06d98551 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -164,6 +164,35 @@ This is a practical baseline config with DM pairing, room allowlist, and E2EE en ## E2EE setup +## Bot to bot rooms + +By default, Matrix messages from other configured OpenClaw Matrix accounts are ignored. + +Use `allowBots` when you intentionally want inter-agent Matrix traffic: + +```json5 +{ + channels: { + matrix: { + allowBots: "mentions", // true | "mentions" + groups: { + "!roomid:example.org": { + requireMention: true, + }, + }, + }, + }, +} +``` + +- `allowBots: true` accepts messages from other configured Matrix bot accounts in allowed rooms and DMs. +- `allowBots: "mentions"` accepts those messages only when they visibly mention this bot in rooms. DMs are still allowed. +- `groups..allowBots` overrides the account-level setting for one room. +- OpenClaw still ignores messages from the same Matrix user ID to avoid self-reply loops. +- Matrix does not expose a native bot flag here; OpenClaw treats "bot-authored" as "sent by another configured Matrix account on this OpenClaw gateway". + +Use strict room allowlists and mention requirements when enabling bot-to-bot traffic in shared rooms. + Enable encryption: ```json5 @@ -580,6 +609,7 @@ Live directory lookup uses the logged-in Matrix account: - `name`: optional label for the account. - `defaultAccount`: preferred account ID when multiple Matrix accounts are configured. - `homeserver`: homeserver URL, for example `https://matrix.example.org`. +- `allowPrivateNetwork`: allow this Matrix account to connect to private/internal homeservers. Enable this when the homeserver resolves to `localhost`, a LAN/Tailscale IP, or an internal host such as `matrix-synapse`. - `userId`: full Matrix user ID, for example `@bot:example.org`. - `accessToken`: access token for token-based auth. - `password`: password for password-based login. diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index b4685098e13..33b2e3f6174 100644 --- a/extensions/matrix/src/config-schema.ts +++ b/extensions/matrix/src/config-schema.ts @@ -34,6 +34,7 @@ const matrixRoomSchema = z enabled: z.boolean().optional(), allow: z.boolean().optional(), requireMention: z.boolean().optional(), + allowBots: z.union([z.boolean(), z.literal("mentions")]).optional(), tools: ToolPolicySchema, autoReply: z.boolean().optional(), users: AllowFromListSchema, @@ -49,6 +50,7 @@ export const MatrixConfigSchema = z.object({ accounts: z.record(z.string(), z.unknown()).optional(), markdown: MarkdownConfigSchema, homeserver: z.string().optional(), + allowPrivateNetwork: z.boolean().optional(), userId: z.string().optional(), accessToken: z.string().optional(), password: buildSecretInputSchema().optional(), @@ -58,6 +60,7 @@ export const MatrixConfigSchema = z.object({ initialSyncLimit: z.number().optional(), encryption: z.boolean().optional(), allowlistOnly: z.boolean().optional(), + allowBots: z.union([z.boolean(), z.literal("mentions")]).optional(), groupPolicy: GroupPolicySchema.optional(), replyToMode: z.enum(["off", "first", "all"]).optional(), threadReplies: z.enum(["off", "inbound", "always"]).optional(), diff --git a/extensions/matrix/src/matrix/accounts.test.ts b/extensions/matrix/src/matrix/accounts.test.ts index 8480ef0e94b..9b098f47b87 100644 --- a/extensions/matrix/src/matrix/accounts.test.ts +++ b/extensions/matrix/src/matrix/accounts.test.ts @@ -3,12 +3,21 @@ import { getMatrixScopedEnvVarNames } from "../env-vars.js"; import type { CoreConfig } from "../types.js"; import { listMatrixAccountIds, + resolveConfiguredMatrixBotUserIds, resolveDefaultMatrixAccountId, resolveMatrixAccount, } from "./accounts.js"; +import type { MatrixStoredCredentials } from "./credentials-read.js"; + +const loadMatrixCredentialsMock = vi.hoisted(() => + vi.fn<(env?: NodeJS.ProcessEnv, accountId?: string | null) => MatrixStoredCredentials | null>( + () => null, + ), +); vi.mock("./credentials-read.js", () => ({ - loadMatrixCredentials: () => null, + loadMatrixCredentials: (env?: NodeJS.ProcessEnv, accountId?: string | null) => + loadMatrixCredentialsMock(env, accountId), credentialsMatchConfig: () => false, })); @@ -28,6 +37,7 @@ describe("resolveMatrixAccount", () => { let prevEnv: Record = {}; beforeEach(() => { + loadMatrixCredentialsMock.mockReset().mockReturnValue(null); prevEnv = {}; for (const key of envKeys) { prevEnv[key] = process.env[key]; @@ -195,4 +205,66 @@ describe("resolveMatrixAccount", () => { expect(resolveDefaultMatrixAccountId(cfg)).toBe("default"); }); + + it("collects other configured Matrix account user ids for bot detection", () => { + const cfg: CoreConfig = { + channels: { + matrix: { + userId: "@main:example.org", + homeserver: "https://matrix.example.org", + accessToken: "main-token", + accounts: { + ops: { + homeserver: "https://matrix.example.org", + userId: "@ops:example.org", + accessToken: "ops-token", + }, + alerts: { + homeserver: "https://matrix.example.org", + userId: "@alerts:example.org", + accessToken: "alerts-token", + }, + }, + }, + }, + }; + + expect( + Array.from(resolveConfiguredMatrixBotUserIds({ cfg, accountId: "ops" })).toSorted(), + ).toEqual(["@alerts:example.org", "@main:example.org"]); + }); + + it("falls back to stored credentials when an access-token-only account omits userId", () => { + loadMatrixCredentialsMock.mockImplementation( + (env?: NodeJS.ProcessEnv, accountId?: string | null) => + accountId === "ops" + ? { + homeserver: "https://matrix.example.org", + userId: "@ops:example.org", + accessToken: "ops-token", + createdAt: "2026-03-19T00:00:00.000Z", + } + : null, + ); + + const cfg: CoreConfig = { + channels: { + matrix: { + userId: "@main:example.org", + homeserver: "https://matrix.example.org", + accessToken: "main-token", + accounts: { + ops: { + homeserver: "https://matrix.example.org", + accessToken: "ops-token", + }, + }, + }, + }, + }; + + expect(Array.from(resolveConfiguredMatrixBotUserIds({ cfg, accountId: "default" }))).toEqual([ + "@ops:example.org", + ]); + }); }); diff --git a/extensions/matrix/src/matrix/accounts.ts b/extensions/matrix/src/matrix/accounts.ts index 13e33a259a6..8e0fdaa5a5a 100644 --- a/extensions/matrix/src/matrix/accounts.ts +++ b/extensions/matrix/src/matrix/accounts.ts @@ -38,6 +38,31 @@ export type ResolvedMatrixAccount = { config: MatrixConfig; }; +function resolveMatrixAccountUserId(params: { + cfg: CoreConfig; + accountId: string; + env?: NodeJS.ProcessEnv; +}): string | null { + const env = params.env ?? process.env; + const resolved = resolveMatrixConfigForAccount(params.cfg, params.accountId, env); + const configuredUserId = resolved.userId.trim(); + if (configuredUserId) { + return configuredUserId; + } + + const stored = loadMatrixCredentials(env, params.accountId); + if (!stored) { + return null; + } + if (resolved.homeserver && stored.homeserver !== resolved.homeserver) { + return null; + } + if (resolved.accessToken && stored.accessToken !== resolved.accessToken) { + return null; + } + return stored.userId.trim() || null; +} + export function listMatrixAccountIds(cfg: CoreConfig): string[] { const ids = resolveConfiguredMatrixAccountIds(cfg, process.env); return ids.length > 0 ? ids : [DEFAULT_ACCOUNT_ID]; @@ -47,6 +72,39 @@ export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string { return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg)); } +export function resolveConfiguredMatrixBotUserIds(params: { + cfg: CoreConfig; + accountId?: string | null; + env?: NodeJS.ProcessEnv; +}): Set { + const env = params.env ?? process.env; + const currentAccountId = normalizeAccountId(params.accountId); + const accountIds = new Set(resolveConfiguredMatrixAccountIds(params.cfg, env)); + if (resolveMatrixAccount({ cfg: params.cfg, accountId: DEFAULT_ACCOUNT_ID }).configured) { + accountIds.add(DEFAULT_ACCOUNT_ID); + } + const ids = new Set(); + + for (const accountId of accountIds) { + if (normalizeAccountId(accountId) === currentAccountId) { + continue; + } + if (!resolveMatrixAccount({ cfg: params.cfg, accountId }).configured) { + continue; + } + const userId = resolveMatrixAccountUserId({ + cfg: params.cfg, + accountId, + env, + }); + if (userId) { + ids.add(userId); + } + } + + return ids; +} + export function resolveMatrixAccount(params: { cfg: CoreConfig; accountId?: string | null; diff --git a/extensions/matrix/src/matrix/config-update.test.ts b/extensions/matrix/src/matrix/config-update.test.ts index a5428e833e2..da62ffef184 100644 --- a/extensions/matrix/src/matrix/config-update.test.ts +++ b/extensions/matrix/src/matrix/config-update.test.ts @@ -55,6 +55,31 @@ describe("updateMatrixAccountConfig", () => { expect(updated.channels?.["matrix"]?.accounts?.default?.userId).toBeUndefined(); }); + it("stores and clears Matrix allowBots and allowPrivateNetwork settings", () => { + const cfg = { + channels: { + matrix: { + accounts: { + default: { + allowBots: true, + allowPrivateNetwork: true, + }, + }, + }, + }, + } as CoreConfig; + + const updated = updateMatrixAccountConfig(cfg, "default", { + allowBots: "mentions", + allowPrivateNetwork: null, + }); + + expect(updated.channels?.["matrix"]?.accounts?.default).toMatchObject({ + allowBots: "mentions", + }); + expect(updated.channels?.["matrix"]?.accounts?.default?.allowPrivateNetwork).toBeUndefined(); + }); + it("normalizes account id and defaults account enabled=true", () => { const updated = updateMatrixAccountConfig({} as CoreConfig, "Main Bot", { name: "Main Bot", diff --git a/extensions/matrix/src/matrix/config-update.ts b/extensions/matrix/src/matrix/config-update.ts index 1531306e0ab..056ad7ce81a 100644 --- a/extensions/matrix/src/matrix/config-update.ts +++ b/extensions/matrix/src/matrix/config-update.ts @@ -7,6 +7,7 @@ export type MatrixAccountPatch = { name?: string | null; enabled?: boolean; homeserver?: string | null; + allowPrivateNetwork?: boolean | null; userId?: string | null; accessToken?: string | null; password?: string | null; @@ -15,6 +16,7 @@ export type MatrixAccountPatch = { avatarUrl?: string | null; encryption?: boolean | null; initialSyncLimit?: number | null; + allowBots?: MatrixConfig["allowBots"] | null; dm?: MatrixConfig["dm"] | null; groupPolicy?: MatrixConfig["groupPolicy"] | null; groupAllowFrom?: MatrixConfig["groupAllowFrom"] | null; @@ -144,6 +146,14 @@ export function updateMatrixAccountConfig( applyNullableStringField(nextAccount, "deviceName", patch.deviceName); applyNullableStringField(nextAccount, "avatarUrl", patch.avatarUrl); + if (patch.allowPrivateNetwork !== undefined) { + if (patch.allowPrivateNetwork === null) { + delete nextAccount.allowPrivateNetwork; + } else { + nextAccount.allowPrivateNetwork = patch.allowPrivateNetwork; + } + } + if (patch.initialSyncLimit !== undefined) { if (patch.initialSyncLimit === null) { delete nextAccount.initialSyncLimit; @@ -159,6 +169,13 @@ export function updateMatrixAccountConfig( nextAccount.encryption = patch.encryption; } } + if (patch.allowBots !== undefined) { + if (patch.allowBots === null) { + delete nextAccount.allowBots; + } else { + nextAccount.allowBots = patch.allowBots; + } + } if (patch.dm !== undefined) { if (patch.dm === null) { delete nextAccount.dm; diff --git a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts index 7a04948a191..3aa13a735a0 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts @@ -24,6 +24,8 @@ type MatrixHandlerTestHarnessOptions = { allowFrom?: string[]; groupAllowFrom?: string[]; roomsConfig?: Record; + accountAllowBots?: boolean | "mentions"; + configuredBotUserIds?: Set; mentionRegexes?: MatrixMonitorHandlerParams["mentionRegexes"]; groupPolicy?: "open" | "allowlist" | "disabled"; replyToMode?: ReplyToMode; @@ -164,6 +166,8 @@ export function createMatrixHandlerTestHarness( allowFrom: options.allowFrom ?? [], groupAllowFrom: options.groupAllowFrom ?? [], roomsConfig: options.roomsConfig, + accountAllowBots: options.accountAllowBots, + configuredBotUserIds: options.configuredBotUserIds, mentionRegexes: options.mentionRegexes ?? [], groupPolicy: options.groupPolicy ?? "open", replyToMode: options.replyToMode ?? "off", diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index 538de6c9a80..289623631fa 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -260,6 +260,172 @@ describe("matrix monitor handler pairing account scope", () => { expect(enqueueSystemEvent).not.toHaveBeenCalled(); }); + it("drops room messages from configured Matrix bot accounts when allowBots is off", async () => { + const { handler, resolveAgentRoute, recordInboundSession } = createMatrixHandlerTestHarness({ + isDirectMessage: false, + configuredBotUserIds: new Set(["@ops:example.org"]), + roomsConfig: { + "!room:example.org": { requireMention: false }, + }, + getMemberDisplayName: async () => "ops-bot", + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$bot-off", + sender: "@ops:example.org", + body: "hello from bot", + }), + ); + + expect(resolveAgentRoute).not.toHaveBeenCalled(); + expect(recordInboundSession).not.toHaveBeenCalled(); + }); + + it("accepts room messages from configured Matrix bot accounts when allowBots is true", async () => { + const { handler, resolveAgentRoute, recordInboundSession } = createMatrixHandlerTestHarness({ + isDirectMessage: false, + accountAllowBots: true, + configuredBotUserIds: new Set(["@ops:example.org"]), + roomsConfig: { + "!room:example.org": { requireMention: false }, + }, + getMemberDisplayName: async () => "ops-bot", + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$bot-on", + sender: "@ops:example.org", + body: "hello from bot", + }), + ); + + expect(resolveAgentRoute).toHaveBeenCalled(); + expect(recordInboundSession).toHaveBeenCalled(); + }); + + it("does not treat unconfigured Matrix users as bots when allowBots is off", async () => { + const { handler, resolveAgentRoute, recordInboundSession } = createMatrixHandlerTestHarness({ + isDirectMessage: false, + configuredBotUserIds: new Set(["@ops:example.org"]), + roomsConfig: { + "!room:example.org": { requireMention: false }, + }, + getMemberDisplayName: async () => "human", + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$non-bot", + sender: "@alice:example.org", + body: "hello from human", + }), + ); + + expect(resolveAgentRoute).toHaveBeenCalled(); + expect(recordInboundSession).toHaveBeenCalled(); + }); + + it('drops configured Matrix bot room messages without a mention when allowBots="mentions"', async () => { + const { handler, resolveAgentRoute, recordInboundSession } = createMatrixHandlerTestHarness({ + isDirectMessage: false, + accountAllowBots: "mentions", + configuredBotUserIds: new Set(["@ops:example.org"]), + roomsConfig: { + "!room:example.org": { requireMention: false }, + }, + mentionRegexes: [/@bot/i], + getMemberDisplayName: async () => "ops-bot", + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$bot-mentions-off", + sender: "@ops:example.org", + body: "hello from bot", + }), + ); + + expect(resolveAgentRoute).not.toHaveBeenCalled(); + expect(recordInboundSession).not.toHaveBeenCalled(); + }); + + it('accepts configured Matrix bot room messages with a mention when allowBots="mentions"', async () => { + const { handler, resolveAgentRoute, recordInboundSession } = createMatrixHandlerTestHarness({ + isDirectMessage: false, + accountAllowBots: "mentions", + configuredBotUserIds: new Set(["@ops:example.org"]), + roomsConfig: { + "!room:example.org": { requireMention: false }, + }, + mentionRegexes: [/@bot/i], + getMemberDisplayName: async () => "ops-bot", + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$bot-mentions-on", + sender: "@ops:example.org", + body: "hello @bot", + mentions: { user_ids: ["@bot:example.org"] }, + }), + ); + + expect(resolveAgentRoute).toHaveBeenCalled(); + expect(recordInboundSession).toHaveBeenCalled(); + }); + + it('accepts configured Matrix bot DMs without a mention when allowBots="mentions"', async () => { + const { handler, resolveAgentRoute, recordInboundSession } = createMatrixHandlerTestHarness({ + isDirectMessage: true, + accountAllowBots: "mentions", + configuredBotUserIds: new Set(["@ops:example.org"]), + getMemberDisplayName: async () => "ops-bot", + }); + + await handler( + "!dm:example.org", + createMatrixTextMessageEvent({ + eventId: "$bot-dm-mentions", + sender: "@ops:example.org", + body: "hello from dm bot", + }), + ); + + expect(resolveAgentRoute).toHaveBeenCalled(); + expect(recordInboundSession).toHaveBeenCalled(); + }); + + it("lets room-level allowBots override a permissive account default", async () => { + const { handler, resolveAgentRoute, recordInboundSession } = createMatrixHandlerTestHarness({ + isDirectMessage: false, + accountAllowBots: true, + configuredBotUserIds: new Set(["@ops:example.org"]), + roomsConfig: { + "!room:example.org": { requireMention: false, allowBots: false }, + }, + getMemberDisplayName: async () => "ops-bot", + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$bot-room-override", + sender: "@ops:example.org", + body: "hello from bot", + }), + ); + + expect(resolveAgentRoute).not.toHaveBeenCalled(); + expect(recordInboundSession).not.toHaveBeenCalled(); + }); + it("drops forged metadata-only mentions before agent routing", async () => { const { handler, recordInboundSession, resolveAgentRoute } = createMatrixHandlerTestHarness({ isDirectMessage: false, diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index c2b909bdf5c..b7295009bcd 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -46,6 +46,7 @@ import { isMatrixVerificationRoomMessage } from "./verification-utils.js"; const ALLOW_FROM_STORE_CACHE_TTL_MS = 30_000; const PAIRING_REPLY_COOLDOWN_MS = 5 * 60_000; const MAX_TRACKED_PAIRING_REPLY_SENDERS = 512; +type MatrixAllowBotsMode = "off" | "mentions" | "all"; export type MatrixMonitorHandlerParams = { client: MatrixClient; @@ -58,6 +59,8 @@ export type MatrixMonitorHandlerParams = { allowFrom: string[]; groupAllowFrom?: string[]; roomsConfig?: Record; + accountAllowBots?: boolean | "mentions"; + configuredBotUserIds?: ReadonlySet; mentionRegexes: ReturnType; groupPolicy: "open" | "allowlist" | "disabled"; replyToMode: ReplyToMode; @@ -125,6 +128,16 @@ function resolveMatrixInboundBodyText(params: { }); } +function resolveMatrixAllowBotsMode(value?: boolean | "mentions"): MatrixAllowBotsMode { + if (value === true) { + return "all"; + } + if (value === "mentions") { + return "mentions"; + } + return "off"; +} + export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) { const { client, @@ -137,6 +150,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam allowFrom, groupAllowFrom = [], roomsConfig, + accountAllowBots, + configuredBotUserIds = new Set(), mentionRegexes, groupPolicy, replyToMode, @@ -305,12 +320,21 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }) : undefined; const roomConfig = roomConfigInfo?.config; + const allowBotsMode = resolveMatrixAllowBotsMode(roomConfig?.allowBots ?? accountAllowBots); + const isConfiguredBotSender = configuredBotUserIds.has(senderId); const roomMatchMeta = roomConfigInfo ? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${ roomConfigInfo.matchSource ?? "none" }` : "matchKey=none matchSource=none"; + if (isConfiguredBotSender && allowBotsMode === "off") { + logVerboseMessage( + `matrix: drop configured bot sender=${senderId} (allowBots=false${isDirectMessage ? "" : `, ${roomMatchMeta}`})`, + ); + return; + } + if (isRoom && roomConfig && !roomConfigInfo?.allowed) { logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`); return; @@ -476,6 +500,17 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam text: mentionPrecheckText, mentionRegexes, }); + if ( + isConfiguredBotSender && + allowBotsMode === "mentions" && + !isDirectMessage && + !wasMentioned + ) { + logVerboseMessage( + `matrix: drop configured bot sender=${senderId} (allowBots=mentions, missing mention, ${roomMatchMeta})`, + ); + return; + } const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg, surface: "matrix", diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 957d629440c..62ea41b0169 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -10,7 +10,7 @@ import { } from "../../runtime-api.js"; import { getMatrixRuntime } from "../../runtime.js"; import type { CoreConfig, ReplyToMode } from "../../types.js"; -import { resolveMatrixAccount } from "../accounts.js"; +import { resolveConfiguredMatrixBotUserIds, resolveMatrixAccount } from "../accounts.js"; import { setActiveMatrixClient } from "../active-client.js"; import { isBunRuntime, @@ -80,10 +80,15 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi const accountConfig = account.config; const allowlistOnly = accountConfig.allowlistOnly === true; + const accountAllowBots = accountConfig.allowBots; let allowFrom: string[] = (accountConfig.dm?.allowFrom ?? []).map(String); let groupAllowFrom: string[] = (accountConfig.groupAllowFrom ?? []).map(String); let roomsConfig = accountConfig.groups ?? accountConfig.rooms; let needsRoomAliasesForConfig = false; + const configuredBotUserIds = resolveConfiguredMatrixBotUserIds({ + cfg, + accountId: effectiveAccountId, + }); ({ allowFrom, groupAllowFrom, roomsConfig } = await resolveMatrixMonitorConfig({ cfg, @@ -201,6 +206,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi allowFrom, groupAllowFrom, roomsConfig, + accountAllowBots, + configuredBotUserIds, mentionRegexes, groupPolicy, replyToMode, diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts index b904eb9da42..6d64c14d551 100644 --- a/extensions/matrix/src/types.ts +++ b/extensions/matrix/src/types.ts @@ -19,6 +19,11 @@ export type MatrixRoomConfig = { allow?: boolean; /** Require mentioning the bot to trigger replies. */ requireMention?: boolean; + /** + * Allow messages from other configured Matrix bot accounts. + * true accepts all configured bot senders; "mentions" requires they mention this bot. + */ + allowBots?: boolean | "mentions"; /** Optional tool policy overrides for this room. */ tools?: { allow?: string[]; deny?: string[] }; /** If true, reply without mention requirements. */ @@ -63,6 +68,8 @@ export type MatrixConfig = { defaultAccount?: string; /** Matrix homeserver URL (https://matrix.example.org). */ homeserver?: string; + /** Allow Matrix homeserver traffic to private/internal hosts. */ + allowPrivateNetwork?: boolean; /** Matrix user id (@user:server). */ userId?: string; /** Matrix access token. */ @@ -81,6 +88,11 @@ export type MatrixConfig = { encryption?: boolean; /** If true, enforce allowlists for groups + DMs regardless of policy. */ allowlistOnly?: boolean; + /** + * Allow messages from other configured Matrix bot accounts. + * true accepts all configured bot senders; "mentions" requires they mention this bot. + */ + allowBots?: boolean | "mentions"; /** Group message policy (default: allowlist). */ groupPolicy?: GroupPolicy; /** Allowlist for group senders (matrix user IDs). */ diff --git a/src/channels/plugins/setup-helpers.test.ts b/src/channels/plugins/setup-helpers.test.ts index 2ccf7648c68..e48aa5df3a1 100644 --- a/src/channels/plugins/setup-helpers.test.ts +++ b/src/channels/plugins/setup-helpers.test.ts @@ -165,6 +165,34 @@ describe("createPatchedAccountSetupAdapter", () => { }); describe("moveSingleAccountChannelSectionToDefaultAccount", () => { + it("moves Matrix allowBots into the promoted default account", () => { + const next = moveSingleAccountChannelSectionToDefaultAccount({ + cfg: asConfig({ + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", + allowBots: "mentions", + }, + }, + }), + channelKey: "matrix", + }); + + expect(next.channels?.matrix).toMatchObject({ + accounts: { + default: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", + allowBots: "mentions", + }, + }, + }); + expect(next.channels?.matrix?.allowBots).toBeUndefined(); + }); + it("promotes legacy Matrix keys into the sole named account when defaultAccount is unset", () => { const next = moveSingleAccountChannelSectionToDefaultAccount({ cfg: asConfig({ diff --git a/src/channels/plugins/setup-helpers.ts b/src/channels/plugins/setup-helpers.ts index 269bffe7565..8c4f27beeca 100644 --- a/src/channels/plugins/setup-helpers.ts +++ b/src/channels/plugins/setup-helpers.ts @@ -342,6 +342,7 @@ const SINGLE_ACCOUNT_KEYS_TO_MOVE_BY_CHANNEL: Record "initialSyncLimit", "encryption", "allowlistOnly", + "allowBots", "replyToMode", "threadReplies", "textChunkLimit", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 684246b9ddc..bcaec953d57 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -729,6 +729,8 @@ export const FIELD_HELP: Record = { auth: "Authentication profile root used for multi-profile provider credentials and cooldown-based failover ordering. Keep profiles minimal and explicit so automatic failover behavior stays auditable.", "channels.slack.allowBots": "Allow bot-authored messages to trigger Slack replies (default: false).", + "channels.matrix.allowBots": + 'Allow messages from other configured Matrix bot accounts to trigger replies (default: false). Set "mentions" to only accept bot messages that visibly mention this bot.', "channels.slack.thread.historyScope": 'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).', "channels.slack.thread.inheritParent": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 1684d3c3ee6..854975b5a9c 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -807,6 +807,7 @@ export const FIELD_LABELS: Record = { "channels.slack.commands.nativeSkills": "Slack Native Skill Commands", "channels.slack.allowBots": "Slack Allow Bot Messages", "channels.discord.allowBots": "Discord Allow Bot Messages", + "channels.matrix.allowBots": "Matrix Allow Bot Messages", "channels.discord.token": "Discord Bot Token", "channels.slack.botToken": "Slack Bot Token", "channels.slack.appToken": "Slack App Token",