diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index ea3ddc2ecf1..94fdf6fcf8f 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -61,7 +61,7 @@ Wizard behavior that matters: - When you add another Matrix account interactively, the entered account name is normalized into the account ID used in config and env vars. For example, `Ops Bot` becomes `ops-bot`. - DM allowlist prompts accept full `@user:server` values immediately. Display names only work when live directory lookup finds one exact match; otherwise the wizard asks you to retry with a full Matrix ID. - Room allowlist prompts accept room IDs and aliases directly. They can also resolve joined-room names live, but unresolved names are only kept as typed during setup and are ignored later by runtime allowlist resolution. Prefer `!room:server` or `#alias:server`. -- Runtime room/session identity uses the stable Matrix room ID. Room-declared aliases are only used as lookup inputs, not as the long-term session key or group channel identifier. +- Runtime room/session identity uses the stable Matrix room ID. Room-declared aliases are only used as lookup inputs, not as the long-term session key or stable group identity. - To resolve room names before saving them, use `openclaw channels resolve --channel matrix "Project Room"`. Minimal token-based setup: @@ -580,6 +580,6 @@ Live directory lookup uses the logged-in Matrix account: - `dm`: DM policy block (`enabled`, `policy`, `allowFrom`). - `dm.allowFrom` entries should be full Matrix user IDs unless you already resolved them through live directory lookup. - `accounts`: named per-account overrides. Top-level `channels.matrix` values act as defaults for these entries. -- `groups`: per-room policy map. Prefer room IDs or aliases; unresolved room names are ignored at runtime. Session/group metadata uses the stable room ID after resolution. +- `groups`: per-room policy map. Prefer room IDs or aliases; unresolved room names are ignored at runtime. Session/group identity uses the stable room ID after resolution, while human-readable labels still come from room names. - `rooms`: legacy alias for `groups`. - `actions`: per-action tool gating (`messages`, `reactions`, `pins`, `memberInfo`, `channelInfo`, `verification`). diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 71a557de606..52bef140973 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -305,8 +305,10 @@ Each session entry records where it came from (best-effort) in `origin`: connector only updates delivery routing (for example, to keep a DM main session fresh), it should still provide inbound context so the session keeps its explainer metadata. Extensions can do this by sending `ConversationLabel`, - `GroupSubject`, `GroupChannel`, `GroupSpace`, and `SenderName` in the inbound - context and calling `recordSessionMetaFromInbound` (or passing the same context - to `updateLastRoute`). - `GroupChannel` should carry the stable provider-side channel identity when one - exists. For example, Matrix now uses the room ID instead of room-declared aliases. + `GroupSubject`, `GroupId`, `GroupChannel`, `GroupSpace`, and `SenderName` in + the inbound context and calling `recordSessionMetaFromInbound` (or passing the + same context to `updateLastRoute`). + `GroupId` should carry the stable provider-side group/channel identity when one + exists. `GroupChannel` remains the human-readable channel label. For example, + Matrix now uses the room ID in `GroupId` and keeps room labels separate from + that stable identity. diff --git a/docs/zh-CN/channels/matrix.md b/docs/zh-CN/channels/matrix.md index 074ae341f1b..0af138f471a 100644 --- a/docs/zh-CN/channels/matrix.md +++ b/docs/zh-CN/channels/matrix.md @@ -164,7 +164,7 @@ E2EE 配置(启用端到端加密): - 每个房间的 `users` 允许列表可以进一步限制特定房间内的发送者(需完整 Matrix 用户 ID)。 - 配置向导会提示输入房间允许列表(房间 ID、别名或名称),仅在精确且唯一匹配时解析名称。 - 启动时,OpenClaw 将允许列表中的房间/用户名称解析为 ID 并记录映射;未解析的条目不会参与允许列表匹配。 -- 运行时的房间/会话标识使用稳定的 Matrix 房间 ID。房间自行声明的别名只作为查找输入,不作为长期会话键或群组频道标识。 +- 运行时的房间/会话标识使用稳定的 Matrix 房间 ID。房间自行声明的别名只作为查找输入,不作为长期会话键或稳定的群组标识。 - 默认**不**自动加入邀请;使用 `channels.matrix.autoJoin` 和 `channels.matrix.autoJoinAllowlist` 控制。 - 要**禁止所有房间**,设置 `channels.matrix.groupPolicy: "disabled"`(或保持空的允许列表)。 - 旧版键名:`channels.matrix.rooms`(与 `groups` 相同的结构)。 @@ -213,7 +213,7 @@ E2EE 配置(启用端到端加密): - `channels.matrix.groupPolicy`:`allowlist | open | disabled`(默认:allowlist)。 - `channels.matrix.groupAllowFrom`:群组消息的允许发送者列表(需完整 Matrix 用户 ID)。 - `channels.matrix.allowlistOnly`:强制私信 + 房间使用允许列表规则。 -- `channels.matrix.groups`:群组允许列表 + 每个房间的设置映射。运行时解析后,会话/群组元数据使用稳定的房间 ID。 +- `channels.matrix.groups`:群组允许列表 + 每个房间的设置映射。运行时解析后,会话/群组身份使用稳定的房间 ID,而人类可读标签仍来自房间名称。 - `channels.matrix.rooms`:旧版群组允许列表/配置。 - `channels.matrix.replyToMode`:话题/标签的 reply-to 模式。 - `channels.matrix.mediaMaxMb`:入站/出站媒体上限(MB)。 diff --git a/docs/zh-CN/concepts/session.md b/docs/zh-CN/concepts/session.md index d1edb7744c2..56a88428697 100644 --- a/docs/zh-CN/concepts/session.md +++ b/docs/zh-CN/concepts/session.md @@ -163,4 +163,4 @@ OpenClaw 将**每个智能体的一个直接聊天会话**视为主会话。直 - `from`/`to`:入站信封中的原始路由 ID - `accountId`:提供商账户 ID(多账户时) - `threadId`:渠道支持时的线程/话题 ID - 来源字段为私信、频道和群组填充。如果连接器仅更新投递路由(例如,保持私信主会话新鲜),它仍应提供入站上下文,以便会话保留其解释器元数据。扩展可以通过在入站上下文中发送 `ConversationLabel`、`GroupSubject`、`GroupChannel`、`GroupSpace` 和 `SenderName` 并调用 `recordSessionMetaFromInbound`(或将相同上下文传递给 `updateLastRoute`)来实现。`GroupChannel` 在提供商有稳定频道标识时应携带该稳定标识。例如,Matrix 现在使用房间 ID,而不是房间自行声明的别名。 + 来源字段为私信、频道和群组填充。如果连接器仅更新投递路由(例如,保持私信主会话新鲜),它仍应提供入站上下文,以便会话保留其解释器元数据。扩展可以通过在入站上下文中发送 `ConversationLabel`、`GroupSubject`、`GroupId`、`GroupChannel`、`GroupSpace` 和 `SenderName` 并调用 `recordSessionMetaFromInbound`(或将相同上下文传递给 `updateLastRoute`)来实现。`GroupId` 在提供商有稳定群组/频道标识时应携带该稳定标识。`GroupChannel` 继续表示人类可读的频道标签。例如,Matrix 现在把房间 ID 放在 `GroupId` 中,并将房间标签与该稳定标识分开。 diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index 45add7bbbe2..b4d5595e01d 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -412,12 +412,14 @@ describe("matrix monitor handler pairing account scope", () => { }), ); - expect(finalizeInboundContext).toHaveBeenCalledWith( + const finalized = vi.mocked(finalizeInboundContext).mock.calls.at(-1)?.[0]; + expect(finalized).toEqual( expect.objectContaining({ GroupSubject: "Ops Room", - GroupChannel: "!room:example.org", + GroupId: "!room:example.org", }), ); + expect(finalized).not.toHaveProperty("GroupChannel"); }); it("routes bound Matrix threads to the target session key", async () => { diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 4d11647123a..0ea035e84a2 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -630,7 +630,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam SenderId: senderId, SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""), GroupSubject: isRoom ? (roomName ?? roomId) : undefined, - GroupChannel: isRoom ? roomId : undefined, + GroupId: isRoom ? roomId : undefined, GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined, Provider: "matrix" as const, Surface: "matrix" as const, diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index dcf398d5a4b..168a0b0490d 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -63,7 +63,7 @@ export function resolveGroupRequireMention(params: { if (!channel) { return true; } - const groupId = groupResolution?.id ?? extractGroupId(ctx.From); + const groupId = groupResolution?.id ?? ctx.GroupId?.trim() ?? extractGroupId(ctx.From); const groupChannel = ctx.GroupChannel?.trim() ?? ctx.GroupSubject?.trim(); const groupSpace = ctx.GroupSpace?.trim(); let requireMention: boolean | undefined; @@ -153,7 +153,10 @@ export function buildGroupIntro(params: { activation === "always" ? "Activation: always-on (you receive every group message)." : "Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included)."; - const groupId = params.sessionEntry?.groupId ?? extractGroupId(params.sessionCtx.From); + const groupId = + params.sessionEntry?.groupId ?? + params.sessionCtx.GroupId?.trim() ?? + extractGroupId(params.sessionCtx.From); const groupChannel = params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(); const groupSpace = params.sessionCtx.GroupSpace?.trim(); diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 8ca3c2389bc..48a8594f504 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -111,6 +111,8 @@ export type MsgContext = { /** Human label for envelope headers (conversation label, not sender). */ ConversationLabel?: string; GroupSubject?: string; + /** Stable provider-side group/channel identity when one exists (for example, a Matrix room ID). */ + GroupId?: string; /** Human label for channel-like group conversations (e.g. #general, #support). */ GroupChannel?: string; GroupSpace?: string; diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index 031b39e9ef7..17661df1038 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -134,6 +134,17 @@ describe("sessions", () => { ctx: { From: "12345-678@g.us", ChatType: "group", Provider: "whatsapp" }, expected: "whatsapp:group:12345-678@g.us", }, + { + name: "uses explicit group ids when inbound routing does not encode the group", + scope: "per-sender" as const, + ctx: { + From: "matrix:@alice:example.org", + ChatType: "channel", + Provider: "matrix", + GroupId: "!room:example.org", + }, + expected: "matrix:channel:!room:example.org", + }, ] as const; for (const testCase of deriveSessionKeyCases) { diff --git a/src/config/sessions/group.ts b/src/config/sessions/group.ts index 22fbd82f4d6..2ca8be24286 100644 --- a/src/config/sessions/group.ts +++ b/src/config/sessions/group.ts @@ -52,6 +52,7 @@ export function buildGroupDisplayName(params: { } export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | null { + const explicitGroupId = ctx.GroupId?.trim(); const from = typeof ctx.From === "string" ? ctx.From.trim() : ""; const chatType = ctx.ChatType?.trim().toLowerCase(); const normalizedChatType = @@ -59,6 +60,7 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu const isWhatsAppGroupId = from.toLowerCase().endsWith("@g.us"); const looksLikeGroup = + Boolean(explicitGroupId) || normalizedChatType === "group" || normalizedChatType === "channel" || from.includes(":group:") || @@ -88,11 +90,9 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu : from.includes(":channel:") || normalizedChatType === "channel" ? "channel" : "group"; - const id = headIsSurface - ? secondIsKind - ? parts.slice(2).join(":") - : parts.slice(1).join(":") - : from; + const id = + explicitGroupId ?? + (headIsSurface ? (secondIsKind ? parts.slice(2).join(":") : parts.slice(1).join(":")) : from); const finalId = id.trim().toLowerCase(); if (!finalId) { return null; diff --git a/src/hooks/message-hook-mappers.test.ts b/src/hooks/message-hook-mappers.test.ts index c365f463ade..2b988aca405 100644 --- a/src/hooks/message-hook-mappers.test.ts +++ b/src/hooks/message-hook-mappers.test.ts @@ -73,6 +73,7 @@ describe("message hook mappers", () => { channelId: "telegram", accountId: "acc-1", conversationId: "telegram:chat:456", + groupId: "telegram:chat:456", }); expect(toPluginMessageReceivedEvent(canonical)).toEqual({ from: "telegram:user:123", @@ -99,6 +100,35 @@ describe("message hook mappers", () => { }); }); + it("prefers explicit group ids and preserves human-readable channel names", () => { + const canonical = deriveInboundMessageHookContext( + makeInboundCtx({ + To: "room:!room:example.org", + OriginatingTo: "room:!room:example.org", + GroupId: "!room:example.org", + GroupChannel: undefined, + GroupSubject: "Ops Room", + }), + ); + + expect(canonical.groupId).toBe("!room:example.org"); + expect(canonical.channelName).toBe("Ops Room"); + expect(toPluginMessageContext(canonical)).toEqual({ + channelId: "telegram", + accountId: "acc-1", + conversationId: "room:!room:example.org", + groupId: "!room:example.org", + }); + expect(toPluginMessageReceivedEvent(canonical)).toEqual( + expect.objectContaining({ + metadata: expect.objectContaining({ + channelName: "Ops Room", + groupId: "!room:example.org", + }), + }), + ); + }); + it("maps transcribed and preprocessed internal payloads", () => { const cfg = {} as OpenClawConfig; const canonical = deriveInboundMessageHookContext(makeInboundCtx({ Transcript: undefined })); @@ -131,6 +161,7 @@ describe("message hook mappers", () => { channelId: "telegram", accountId: "acc-1", conversationId: "telegram:chat:456", + groupId: "telegram:chat:456", }); expect(toPluginMessageSentEvent(canonical)).toEqual({ to: "telegram:chat:456", diff --git a/src/hooks/message-hook-mappers.ts b/src/hooks/message-hook-mappers.ts index 1cdd12a93ac..d80dc0d4799 100644 --- a/src/hooks/message-hook-mappers.ts +++ b/src/hooks/message-hook-mappers.ts @@ -72,7 +72,8 @@ export function deriveInboundMessageHookContext( : ""); const channelId = (ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider ?? "").toLowerCase(); const conversationId = ctx.OriginatingTo ?? ctx.To ?? ctx.From ?? undefined; - const isGroup = Boolean(ctx.GroupSubject || ctx.GroupChannel); + const explicitGroupId = ctx.GroupId?.trim(); + const isGroup = Boolean(explicitGroupId || ctx.GroupSubject || ctx.GroupChannel); return { from: ctx.From ?? "", to: ctx.To, @@ -105,9 +106,9 @@ export function deriveInboundMessageHookContext( originatingChannel: ctx.OriginatingChannel, originatingTo: ctx.OriginatingTo, guildId: ctx.GroupSpace, - channelName: ctx.GroupChannel, + channelName: ctx.GroupChannel ?? ctx.GroupSubject, isGroup, - groupId: isGroup ? conversationId : undefined, + groupId: explicitGroupId ?? (isGroup ? conversationId : undefined), }; } @@ -144,6 +145,7 @@ export function toPluginMessageContext( channelId: canonical.channelId, accountId: canonical.accountId, conversationId: canonical.conversationId, + groupId: canonical.groupId, }; } @@ -168,6 +170,7 @@ export function toPluginMessageReceivedEvent( senderE164: canonical.senderE164, guildId: canonical.guildId, channelName: canonical.channelName, + groupId: canonical.groupId, }, }; } @@ -205,6 +208,7 @@ export function toInternalMessageReceivedContext( senderE164: canonical.senderE164, guildId: canonical.guildId, channelName: canonical.channelName, + groupId: canonical.groupId, }, }; } diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 4c5894ddda1..b8fe08efa71 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -560,6 +560,7 @@ export type PluginHookMessageContext = { channelId: string; accountId?: string; conversationId?: string; + groupId?: string; }; // message_received hook