fix(channels): pin dm main route owners

This commit is contained in:
Vincent Koc
2026-05-01 04:38:23 -07:00
parent 1076d6c124
commit f6a1d70080
5 changed files with 167 additions and 1 deletions

View File

@@ -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.

View File

@@ -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"));

View File

@@ -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) => {

View File

@@ -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",
}),
}),
}),
);
});
});

View File

@@ -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) => {