From f6a1d7008094a105a4c2026b1b6a57d690fc18e4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 1 May 2026 04:38:23 -0700 Subject: [PATCH] fix(channels): pin dm main route owners --- CHANGELOG.md | 1 + .../matrix/src/matrix/monitor/handler.test.ts | 38 ++++++++++ .../matrix/src/matrix/monitor/handler.ts | 27 ++++++- .../monitor.inbound-system-event.test.ts | 75 +++++++++++++++++++ .../mattermost/src/mattermost/monitor.ts | 27 +++++++ 5 files changed, 167 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c38f13c89c..76971c58a99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - LINE/messages: send quick-reply-only payloads with fallback option text instead of accepting the payload and returning an empty delivery. Thanks @vincentkoc. - Auto-reply/docking: require `/dock-*` route switches to start from direct chats, so group or channel participants cannot reroute a shared session's future replies into a linked DM. Thanks @vincentkoc. - Discord: keep text-DM main-session route updates pinned to the configured DM owner, matching component interactions so another direct-message sender cannot redirect future main-session replies. Thanks @vincentkoc. +- Mattermost/Matrix: keep direct-message main-session route updates pinned to the configured DM owner so paired or temporarily allowed senders cannot redirect future shared-session replies. Thanks @vincentkoc. - Gateway/agent: reject strict `openclaw agent --deliver` requests with missing delivery targets before starting the agent run, so users do not wait for a completed turn that cannot send anywhere. Thanks @vincentkoc. - Setup/import: honor non-interactive `--import-from` onboarding flags by running the migration import path instead of silently completing normal setup without importing anything. Thanks @vincentkoc. - Discord/voice: run voice-channel turns under a voice-output policy that hides the agent `tts` tool and asks for spoken reply text, so `/vc join` sessions synthesize and play agent replies instead of ending with `NO_REPLY`. Fixes #61536. Thanks @aounakram. diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index 298df270aa1..429f8685b5e 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -190,6 +190,44 @@ describe("matrix monitor handler pairing account scope", () => { } }); + it("pins direct-message main route updates to the configured owner", async () => { + const { handler, recordInboundSession } = createMatrixHandlerTestHarness({ + cfg: { + channels: { + matrix: { + dm: { allowFrom: ["@owner:example.org"] }, + }, + }, + }, + dmPolicy: "allowlist", + allowFrom: ["@owner:example.org"], + allowFromResolvedEntries: [{ input: "@owner:example.org", id: "@owner:example.org" }], + isDirectMessage: true, + }); + + await handler( + "!dm:example.org", + createMatrixTextMessageEvent({ + eventId: "$owner-dm", + sender: "@owner:example.org", + body: "hello", + }), + ); + + expect(recordInboundSession).toHaveBeenCalledWith( + expect.objectContaining({ + updateLastRoute: expect.objectContaining({ + channel: "matrix", + to: "room:!dm:example.org", + mainDmOwnerPin: expect.objectContaining({ + ownerRecipient: "@owner:example.org", + senderRecipient: "@owner:example.org", + }), + }), + }), + ); + }); + it("sends pairing reminders for pending requests with cooldown", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-01T10:00:00.000Z")); diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 170159b723c..9cdc52eaa8f 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -5,6 +5,7 @@ import { } from "openclaw/plugin-sdk/context-visibility-runtime"; import { hasFinalInboundReplyDispatch } from "openclaw/plugin-sdk/inbound-reply-dispatch"; import type { GetReplyOptions } from "openclaw/plugin-sdk/reply-runtime"; +import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; import { loadSessionStore, resolveSessionStoreEntry, @@ -38,7 +39,7 @@ import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js"; import { resolveMatrixStoredSessionMeta } from "../session-store-metadata.js"; import { resolveMatrixMonitorAccessState } from "./access-state.js"; import { resolveMatrixAckReactionConfig } from "./ack-config.js"; -import { resolveMatrixAllowListMatch } from "./allowlist.js"; +import { normalizeMatrixUserId, resolveMatrixAllowListMatch } from "./allowlist.js"; import { resolveMatrixMonitorLiveUserAllowlist, type MatrixResolvedAllowlistEntry, @@ -1828,6 +1829,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam onReplyStart: typingCallbacks.onReplyStart, onIdle: typingCallbacks.onIdle, }); + const pinnedMainDmOwner = isDirectMessage + ? resolvePinnedMainDmOwnerFromAllowlist({ + dmScope: cfg.session?.dmScope, + allowFrom: liveDmAllowFrom, + normalizeEntry: normalizeMatrixUserId, + }) + : null; const turnResult = await core.channel.turn.run({ channel: "matrix", @@ -1855,6 +1863,23 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam channel: "matrix", to: `room:${roomId}`, accountId: _route.accountId, + mainDmOwnerPin: pinnedMainDmOwner + ? { + ownerRecipient: pinnedMainDmOwner, + senderRecipient: normalizeMatrixUserId(senderId), + onSkip: ({ + ownerRecipient, + senderRecipient, + }: { + ownerRecipient: string; + senderRecipient: string; + }) => { + logVerboseMessage( + `matrix: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, + ); + }, + } + : undefined, } : undefined, onRecordError: (err) => { diff --git a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts index 74cbb2932d3..058ffbbb895 100644 --- a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts @@ -404,4 +404,79 @@ describe("mattermost inbound user posts", () => { Provider: "mattermost", }); }); + + it("pins direct-message main route updates to the configured owner", async () => { + const socket = new FakeWebSocket(); + const abortController = new AbortController(); + mockState.abortController = abortController; + const directConfig: OpenClawConfig = { + channels: { + mattermost: { + enabled: true, + baseUrl: "https://mattermost.example.com", + botToken: "bot-token", + chatmode: "onmessage", + dmPolicy: "allowlist", + groupPolicy: "open", + allowFrom: ["user-1"], + }, + }, + }; + const runtimeCore = createRuntimeCore(directConfig); + mockState.runtimeCore = runtimeCore; + mockState.resolveChannelInfo.mockResolvedValue({ + id: "dm-1", + name: "", + display_name: "", + team_id: "team-1", + type: "D", + }); + const { monitorMattermostProvider } = await import("./monitor.js"); + + const monitor = monitorMattermostProvider({ + config: directConfig, + runtime: testRuntime(), + abortSignal: abortController.signal, + webSocketFactory: () => socket, + }); + + await vi.waitFor(() => { + expect(socket.openListenerCount).toBeGreaterThan(0); + }); + socket.emitOpen(); + + await socket.emitMessage({ + event: "posted", + data: { + channel_id: "dm-1", + sender_name: "alice", + post: JSON.stringify({ + id: "post-dm-1", + channel_id: "dm-1", + user_id: "user-1", + message: "direct hello", + create_at: 1_714_000_000_000, + }), + }, + broadcast: { + channel_id: "dm-1", + user_id: "user-1", + }, + }); + socket.emitClose(1000); + await monitor; + + expect(runtimeCore.channel.session.recordInboundSession).toHaveBeenCalledWith( + expect.objectContaining({ + updateLastRoute: expect.objectContaining({ + channel: "mattermost", + to: "user:user-1", + mainDmOwnerPin: expect.objectContaining({ + ownerRecipient: "user-1", + senderRecipient: "user-1", + }), + }), + }), + ); + }); }); diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index adba19588ae..67afc2e520a 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -1,6 +1,7 @@ import { deliverFinalizableDraftPreview } from "openclaw/plugin-sdk/channel-lifecycle"; import { createClaimableDedupe, type ClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe"; import { isReasoningReplyPayload } from "openclaw/plugin-sdk/reply-payload"; +import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeLowercaseStringOrEmpty, @@ -36,6 +37,7 @@ import { import { authorizeMattermostCommandInvocation, isMattermostSenderAllowed, + normalizeMattermostAllowEntry, normalizeMattermostAllowList, } from "./monitor-auth.js"; import { @@ -1568,6 +1570,14 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} OriginatingTo: to, ...mediaPayload, }); + const pinnedMainDmOwner = + kind === "direct" + ? resolvePinnedMainDmOwnerFromAllowlist({ + dmScope: cfg.session?.dmScope, + allowFrom: account.config.allowFrom, + normalizeEntry: normalizeMattermostAllowEntry, + }) + : null; const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId, @@ -1748,6 +1758,23 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} channel: "mattermost", to, accountId: route.accountId, + mainDmOwnerPin: pinnedMainDmOwner + ? { + ownerRecipient: pinnedMainDmOwner, + senderRecipient: normalizeMattermostAllowEntry(senderId), + onSkip: ({ + ownerRecipient, + senderRecipient, + }: { + ownerRecipient: string; + senderRecipient: string; + }) => { + logVerboseMessage( + `mattermost: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, + ); + }, + } + : undefined, } : undefined, onRecordError: (err) => {