fix(discord): pin text dm main route owner

This commit is contained in:
Vincent Koc
2026-05-01 02:54:47 -07:00
parent 4f02a57f65
commit 1076d6c124
3 changed files with 105 additions and 2 deletions

View File

@@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai
- Telegram/messages: derive fallback text from interactive button/select labels before sending button-only payloads, so Telegram replies are not rejected as empty messages. Thanks @vincentkoc.
- 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.
- 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

@@ -3,6 +3,7 @@ import {
resolveEnvelopeFormatOptions,
} from "openclaw/plugin-sdk/channel-inbound";
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime";
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/conversation-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runtime";
import { buildPendingHistoryContextFromMap } from "openclaw/plugin-sdk/reply-history";
@@ -13,7 +14,7 @@ import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/sess
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime";
import { resolveDiscordConversationIdentity } from "../conversation-identity.js";
import { ChannelType } from "../internal/discord.js";
import { normalizeDiscordSlug } from "./allow-list.js";
import { normalizeDiscordAllowList, normalizeDiscordSlug } from "./allow-list.js";
import { resolveTimestampMs } from "./format.js";
import {
buildDiscordInboundAccessContext,
@@ -28,6 +29,12 @@ import {
import { buildDirectLabel, buildGuildLabel, resolveReplyContext } from "./reply-context.js";
import { resolveDiscordAutoThreadReplyPlan, resolveDiscordThreadStarter } from "./threading.js";
function normalizeDiscordDmOwnerEntry(entry: string): string | undefined {
const normalized = normalizeDiscordAllowList([entry], ["discord:", "user:", "pk:"]);
const candidate = normalized?.ids.values().next().value;
return typeof candidate === "string" && /^\d+$/.test(candidate) ? candidate : undefined;
}
export async function buildDiscordMessageProcessContext(params: {
ctx: DiscordMessagePreflightContext;
text: string;
@@ -104,6 +111,13 @@ export async function buildDiscordMessageProcessContext(params: {
channelTopic: channelInfo?.topic,
messageBody: text,
});
const pinnedMainDmOwner = isDirectMessage
? resolvePinnedMainDmOwnerFromAllowlist({
dmScope: cfg.session?.dmScope,
allowFrom: channelConfig?.users ?? guildInfo?.users,
normalizeEntry: normalizeDiscordDmOwnerEntry,
})
: null;
const contextVisibilityMode = resolveChannelContextVisibilityMode({
cfg,
channel: "discord",
@@ -347,6 +361,24 @@ export async function buildDiscordMessageProcessContext(params: {
channel: "discord",
to: lastRouteTo,
accountId: route.accountId,
mainDmOwnerPin:
isDirectMessage && persistedSessionKey === route.mainSessionKey && pinnedMainDmOwner
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: author.id,
onSkip: ({
ownerRecipient,
senderRecipient,
}: {
ownerRecipient: string;
senderRecipient: string;
}) => {
logVerbose(
`discord: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
}
: undefined,
},
onRecordError: (err: unknown) => {
logVerbose(`discord: failed updating session meta: ${String(err)}`);

View File

@@ -198,6 +198,27 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({
vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
recordInboundSession: (...args: unknown[]) => recordInboundSession(...args),
resolvePinnedMainDmOwnerFromAllowlist: (params: {
dmScope?: string | null;
allowFrom?: Array<string | number> | null;
normalizeEntry: (entry: string) => string | undefined;
}) => {
if ((params.dmScope ?? "main") !== "main") {
return null;
}
const allowFrom = Array.isArray(params.allowFrom) ? params.allowFrom : [];
if (allowFrom.some((entry) => String(entry).trim() === "*")) {
return null;
}
const owners = Array.from(
new Set(
allowFrom
.map((entry) => params.normalizeEntry(String(entry)))
.filter((entry): entry is string => Boolean(entry)),
),
);
return owners.length === 1 ? owners[0] : null;
},
registerSessionBindingAdapter: vi.fn(),
unregisterSessionBindingAdapter: vi.fn(),
resolveThreadBindingConversationIdFromBindingId: (bindingId: string) =>
@@ -306,7 +327,13 @@ beforeEach(() => {
});
function getLastRouteUpdate():
| { sessionKey?: string; channel?: string; to?: string; accountId?: string }
| {
sessionKey?: string;
channel?: string;
to?: string;
accountId?: string;
mainDmOwnerPin?: { ownerRecipient?: string; senderRecipient?: string };
}
| undefined {
const callArgs = recordInboundSession.mock.calls.at(-1) as unknown[] | undefined;
const params = callArgs?.[0] as
@@ -316,6 +343,7 @@ function getLastRouteUpdate():
channel?: string;
to?: string;
accountId?: string;
mainDmOwnerPin?: { ownerRecipient?: string; senderRecipient?: string };
};
}
| undefined;
@@ -782,6 +810,48 @@ describe("processDiscordMessage session routing", () => {
});
});
it("pins Discord text DM main-route updates to the single configured DM owner", async () => {
const ctx = await createBaseContext({
...createDirectMessageContextOverrides(),
cfg: {
messages: { ackReaction: "👀" },
session: {
store: "/tmp/openclaw-discord-process-test-sessions.json",
dmScope: "main",
},
},
channelConfig: { users: ["user:111"] },
baseSessionKey: "agent:main:main",
author: {
id: "222",
username: "bob",
discriminator: "0",
globalName: "Bob",
},
sender: { id: "222", label: "bob" },
route: {
agentId: "main",
channel: "discord",
accountId: "default",
sessionKey: "agent:main:main",
mainSessionKey: "agent:main:main",
},
});
await runProcessDiscordMessage(ctx);
expect(getLastRouteUpdate()).toMatchObject({
sessionKey: "agent:main:main",
channel: "discord",
to: "user:222",
accountId: "default",
mainDmOwnerPin: {
ownerRecipient: "111",
senderRecipient: "222",
},
});
});
it("stores group lastRoute with channel target", async () => {
const ctx = await createBaseContext({
baseSessionKey: "agent:main:discord:channel:c1",