diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a87df0e76f..2377f696583 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -324,6 +324,7 @@ Docs: https://docs.openclaw.ai - Telegram/preview-final edit idempotence: treat `message is not modified` errors during preview finalization as delivered so partial-stream final replies do not fall back to duplicate sends. Landed from contributor PR #34983 by @HOYALIM. Thanks @HOYALIM. - Telegram/DM streaming transport parity: use message preview transport for all DM streaming lanes so final delivery can edit the active preview instead of sending duplicate finals. Landed from contributor PR #38906 by @gambletan. Thanks @gambletan. - Telegram/send retry safety: retry non-idempotent send paths only for pre-connect failures and make custom retry predicates strict, preventing ambiguous reconnect retries from sending duplicate messages. Landed from contributor PR #34238 by @hal-crackbot. Thanks @hal-crackbot. +- Discord/DM session-key normalization: rewrite legacy `discord:dm:*` and phantom direct-message `discord:channel:` session keys to `discord:direct:*` when the sender matches, so multi-agent Discord DMs stop falling into empty channel-shaped sessions and resume replying correctly. ## 2026.3.2 diff --git a/src/config/sessions/session-key.test.ts b/src/config/sessions/session-key.test.ts new file mode 100644 index 00000000000..3bf348d1b76 --- /dev/null +++ b/src/config/sessions/session-key.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import type { MsgContext } from "../../auto-reply/templating.js"; +import { resolveSessionKey } from "./session-key.js"; + +function makeCtx(overrides: Partial): MsgContext { + return { + Body: "", + From: "", + To: "", + ...overrides, + } as MsgContext; +} + +describe("resolveSessionKey", () => { + describe("Discord DM session key normalization", () => { + it("passes through correct discord:direct keys unchanged", () => { + const ctx = makeCtx({ + SessionKey: "agent:fina:discord:direct:123456", + ChatType: "direct", + From: "discord:123456", + SenderId: "123456", + }); + expect(resolveSessionKey("per-sender", ctx)).toBe("agent:fina:discord:direct:123456"); + }); + + it("migrates legacy discord:dm: keys to discord:direct:", () => { + const ctx = makeCtx({ + SessionKey: "agent:fina:discord:dm:123456", + ChatType: "direct", + From: "discord:123456", + SenderId: "123456", + }); + expect(resolveSessionKey("per-sender", ctx)).toBe("agent:fina:discord:direct:123456"); + }); + + it("fixes phantom discord:channel:USERID keys when sender matches", () => { + const ctx = makeCtx({ + SessionKey: "agent:fina:discord:channel:123456", + ChatType: "direct", + From: "discord:123456", + SenderId: "123456", + }); + expect(resolveSessionKey("per-sender", ctx)).toBe("agent:fina:discord:direct:123456"); + }); + + it("does not rewrite discord:channel: keys for non-direct chats", () => { + const ctx = makeCtx({ + SessionKey: "agent:fina:discord:channel:123456", + ChatType: "channel", + From: "discord:channel:123456", + SenderId: "789", + }); + expect(resolveSessionKey("per-sender", ctx)).toBe("agent:fina:discord:channel:123456"); + }); + + it("does not rewrite discord:channel: keys when sender does not match", () => { + const ctx = makeCtx({ + SessionKey: "agent:fina:discord:channel:123456", + ChatType: "direct", + From: "discord:789", + SenderId: "789", + }); + expect(resolveSessionKey("per-sender", ctx)).toBe("agent:fina:discord:channel:123456"); + }); + + it("handles keys without an agent prefix", () => { + const ctx = makeCtx({ + SessionKey: "discord:channel:123456", + ChatType: "direct", + From: "discord:123456", + SenderId: "123456", + }); + expect(resolveSessionKey("per-sender", ctx)).toBe("discord:direct:123456"); + }); + }); +}); diff --git a/src/config/sessions/session-key.ts b/src/config/sessions/session-key.ts index 3244f5c7c60..70c1eba0a8b 100644 --- a/src/config/sessions/session-key.ts +++ b/src/config/sessions/session-key.ts @@ -1,4 +1,5 @@ import type { MsgContext } from "../../auto-reply/templating.js"; +import { normalizeChatType } from "../../channels/chat-type.js"; import { buildAgentMainSessionKey, DEFAULT_AGENT_ID, @@ -28,7 +29,24 @@ export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) { export function resolveSessionKey(scope: SessionScope, ctx: MsgContext, mainKey?: string) { const explicit = ctx.SessionKey?.trim(); if (explicit) { - return explicit.toLowerCase(); + let normalized = explicit.toLowerCase(); + if (normalizeChatType(ctx.ChatType) === "direct") { + normalized = normalized.replace(/^(agent:[^:]+:discord:)dm:/, "$1direct:"); + const match = normalized.match(/^((?:agent:[^:]+:)?)discord:channel:([^:]+)$/); + if (match) { + const from = (ctx.From ?? "").trim().toLowerCase(); + const senderId = (ctx.SenderId ?? "").trim().toLowerCase(); + const fromDiscordId = + from.startsWith("discord:") && !from.includes(":channel:") && !from.includes(":group:") + ? from.slice("discord:".length) + : ""; + const directId = senderId || fromDiscordId; + if (directId && directId === match[2]) { + normalized = `${match[1]}discord:direct:${match[2]}`; + } + } + } + return normalized; } const raw = deriveSessionKey(scope, ctx); if (scope === "global") {