fix(discord): preserve channel session keys via channel_id fallbacks (#17622)

* fix(discord): preserve channel session keys via channel_id fallbacks

* docs(changelog): add discord session continuity note

* Tests: cover discord channel_id fallback

---------

Co-authored-by: Shadow <hi@shadowing.dev>
This commit is contained in:
Shakker
2026-02-16 02:30:17 +00:00
committed by GitHub
parent 39d5590230
commit 09566b1693
16 changed files with 235 additions and 49 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
- Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent.
- Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing `script-src 'self'`. Thanks @Adam55A-code.
- Discord: preserve channel session continuity when runtime payloads omit `message.channelId` by falling back to event/raw `channel_id` values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as `sessionKey=unknown`. (#17622) Thanks @shakkernerd.
- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
- Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx.
- Dev tooling: harden git `pre-commit` hook against option injection from malicious filenames (for example `--force`), preventing accidental staging of ignored files. Thanks @mrthankyou.

View File

@@ -770,7 +770,7 @@ export async function runEmbeddedAttempt(
isCompacting: () => subscription.isCompacting(),
abort: abortRun,
};
setActiveEmbeddedRun(params.sessionId, queueHandle);
setActiveEmbeddedRun(params.sessionId, queueHandle, params.sessionKey);
let abortWarnTimer: NodeJS.Timeout | undefined;
const isProbeSession = params.sessionId?.startsWith("probe-") ?? false;
@@ -1113,7 +1113,7 @@ export async function runEmbeddedAttempt(
`CRITICAL: unsubscribe failed, possible resource leak: runId=${params.runId} ${String(err)}`,
);
}
clearActiveEmbeddedRun(params.sessionId, queueHandle);
clearActiveEmbeddedRun(params.sessionId, queueHandle, params.sessionKey);
params.abortSignal?.removeEventListener?.("abort", onAbort);
}

View File

@@ -115,11 +115,16 @@ function notifyEmbeddedRunEnded(sessionId: string) {
}
}
export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) {
export function setActiveEmbeddedRun(
sessionId: string,
handle: EmbeddedPiQueueHandle,
sessionKey?: string,
) {
const wasActive = ACTIVE_EMBEDDED_RUNS.has(sessionId);
ACTIVE_EMBEDDED_RUNS.set(sessionId, handle);
logSessionStateChange({
sessionId,
sessionKey,
state: "processing",
reason: wasActive ? "run_replaced" : "run_started",
});
@@ -128,10 +133,14 @@ export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueH
}
}
export function clearActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) {
export function clearActiveEmbeddedRun(
sessionId: string,
handle: EmbeddedPiQueueHandle,
sessionKey?: string,
) {
if (ACTIVE_EMBEDDED_RUNS.get(sessionId) === handle) {
ACTIVE_EMBEDDED_RUNS.delete(sessionId);
logSessionStateChange({ sessionId, state: "idle", reason: "run_completed" });
logSessionStateChange({ sessionId, sessionKey, state: "idle", reason: "run_completed" });
if (!sessionId.startsWith("probe-")) {
diag.debug(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
}

View File

@@ -59,6 +59,7 @@ describe("discord processDiscordMessage inbound contract", () => {
attachments: [],
// oxlint-disable-next-line typescript/no-explicit-any
} as any,
messageChannelId: "c1",
author: {
id: "U1",
username: "alice",
@@ -131,6 +132,7 @@ describe("discord processDiscordMessage inbound contract", () => {
timestamp: new Date().toISOString(),
attachments: [],
},
messageChannelId: "c1",
author: {
id: "U1",
username: "alice",

View File

@@ -48,7 +48,11 @@ import {
resolveDiscordSystemLocation,
resolveTimestampMs,
} from "./format.js";
import { resolveDiscordChannelInfo, resolveDiscordMessageText } from "./message-utils.js";
import {
resolveDiscordChannelInfo,
resolveDiscordMessageChannelId,
resolveDiscordMessageText,
} from "./message-utils.js";
import { resolveDiscordSenderIdentity, resolveDiscordWebhookId } from "./sender-identity.js";
import { resolveDiscordSystemEvent } from "./system-events.js";
import { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } from "./threading.js";
@@ -67,6 +71,14 @@ export async function preflightDiscordMessage(
if (!author) {
return null;
}
const messageChannelId = resolveDiscordMessageChannelId({
message,
eventChannelId: params.data.channel_id,
});
if (!messageChannelId) {
logVerbose(`discord: drop message ${message.id} (missing channel id)`);
return null;
}
const allowBots = params.discordConfig?.allowBots ?? false;
if (params.botUserId && author.id === params.botUserId) {
@@ -102,11 +114,11 @@ export async function preflightDiscordMessage(
}
const isGuildMessage = Boolean(params.data.guild_id);
const channelInfo = await resolveDiscordChannelInfo(params.client, message.channelId);
const channelInfo = await resolveDiscordChannelInfo(params.client, messageChannelId);
const isDirectMessage = channelInfo?.type === ChannelType.DM;
const isGroupDm = channelInfo?.type === ChannelType.GroupDM;
logDebug(
`[discord-preflight] channelId=${message.channelId} guild_id=${params.data.guild_id} channelType=${channelInfo?.type} isGuild=${isGuildMessage} isDM=${isDirectMessage} isGroupDm=${isGroupDm}`,
`[discord-preflight] channelId=${messageChannelId} guild_id=${params.data.guild_id} channelType=${channelInfo?.type} isGuild=${isGuildMessage} isDM=${isDirectMessage} isGroupDm=${isGroupDm}`,
);
if (isGroupDm && !params.groupDmEnabled) {
@@ -208,6 +220,7 @@ export async function preflightDiscordMessage(
isGuildMessage,
message,
channelInfo,
messageChannelId,
});
let earlyThreadParentId: string | undefined;
let earlyThreadParentName: string | undefined;
@@ -235,7 +248,7 @@ export async function preflightDiscordMessage(
memberRoleIds,
peer: {
kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
id: isDirectMessage ? author.id : message.channelId,
id: isDirectMessage ? author.id : messageChannelId,
},
// Pass parent peer for thread binding inheritance
parentPeer: earlyThreadParentId ? { kind: "channel", id: earlyThreadParentId } : undefined,
@@ -305,7 +318,7 @@ export async function preflightDiscordMessage(
const channelConfig = isGuildMessage
? resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId: message.channelId,
channelId: messageChannelId,
channelName,
channelSlug: threadChannelSlug,
parentId: threadParentId ?? undefined,
@@ -320,13 +333,13 @@ export async function preflightDiscordMessage(
? `allowed=${channelConfig.allowed} enabled=${channelConfig.enabled ?? "unset"} requireMention=${channelConfig.requireMention ?? "unset"} matchKey=${channelConfig.matchKey ?? "none"} matchSource=${channelConfig.matchSource ?? "none"} users=${channelConfig.users?.length ?? 0} roles=${channelConfig.roles?.length ?? 0} skills=${channelConfig.skills?.length ?? 0}`
: "none";
logDebug(
`[discord-preflight] channelConfig=${channelConfigSummary} channelMatchMeta=${channelMatchMeta} channelId=${message.channelId}`,
`[discord-preflight] channelConfig=${channelConfigSummary} channelMatchMeta=${channelMatchMeta} channelId=${messageChannelId}`,
);
}
if (isGuildMessage && channelConfig?.enabled === false) {
logDebug(`[discord-preflight] drop: channel disabled`);
logVerbose(
`Blocked discord channel ${message.channelId} (channel disabled, ${channelMatchMeta})`,
`Blocked discord channel ${messageChannelId} (channel disabled, ${channelMatchMeta})`,
);
return null;
}
@@ -335,7 +348,7 @@ export async function preflightDiscordMessage(
isGroupDm &&
resolveGroupDmAllow({
channels: params.groupDmChannels,
channelId: message.channelId,
channelId: messageChannelId,
channelName: displayChannelName,
channelSlug: displayChannelSlug,
});
@@ -363,7 +376,7 @@ export async function preflightDiscordMessage(
);
} else {
logVerbose(
`Blocked discord channel ${message.channelId} not in guild channel allowlist (groupPolicy: allowlist, ${channelMatchMeta})`,
`Blocked discord channel ${messageChannelId} not in guild channel allowlist (groupPolicy: allowlist, ${channelMatchMeta})`,
);
}
return null;
@@ -372,13 +385,13 @@ export async function preflightDiscordMessage(
if (isGuildMessage && channelConfig?.allowed === false) {
logDebug(`[discord-preflight] drop: channelConfig.allowed===false`);
logVerbose(
`Blocked discord channel ${message.channelId} not in guild channel allowlist (${channelMatchMeta})`,
`Blocked discord channel ${messageChannelId} not in guild channel allowlist (${channelMatchMeta})`,
);
return null;
}
if (isGuildMessage) {
logDebug(`[discord-preflight] pass: channel allowed`);
logVerbose(`discord: allow channel ${message.channelId} (${channelMatchMeta})`);
logVerbose(`discord: allow channel ${messageChannelId} (${channelMatchMeta})`);
}
const textForHistory = resolveDiscordMessageText(message, {
@@ -467,7 +480,7 @@ export async function preflightDiscordMessage(
);
if (shouldLogVerbose()) {
logVerbose(
`discord: inbound id=${message.id} guild=${params.data.guild_id ?? "dm"} channel=${message.channelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${messageText ? "yes" : "no"}`,
`discord: inbound id=${message.id} guild=${params.data.guild_id ?? "dm"} channel=${messageChannelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${messageText ? "yes" : "no"}`,
);
}
@@ -542,14 +555,14 @@ export async function preflightDiscordMessage(
logVerbose(`discord: drop guild message (mention required, botId=${botId})`);
logger.info(
{
channelId: message.channelId,
channelId: messageChannelId,
reason: "no-mention",
},
"discord: skipping guild message",
);
recordPendingHistoryEntryIfEnabled({
historyMap: params.guildHistories,
historyKey: message.channelId,
historyKey: messageChannelId,
limit: params.historyLimit,
entry: historyEntry ?? null,
});
@@ -567,14 +580,14 @@ export async function preflightDiscordMessage(
isDirectMessage,
isGroupDm,
guild: params.data.guild ?? undefined,
channelName: channelName ?? message.channelId,
channelName: channelName ?? messageChannelId,
});
const systemText = resolveDiscordSystemEvent(message, systemLocation);
if (systemText) {
logDebug(`[discord-preflight] drop: system event`);
enqueueSystemEvent(systemText, {
sessionKey: route.sessionKey,
contextKey: `discord:system:${message.channelId}:${message.id}`,
contextKey: `discord:system:${messageChannelId}:${message.id}`,
});
return null;
}
@@ -603,6 +616,7 @@ export async function preflightDiscordMessage(
data: params.data,
client: params.client,
message,
messageChannelId,
author,
sender,
channelInfo,

View File

@@ -34,6 +34,7 @@ export type DiscordMessagePreflightContext = {
data: DiscordMessageEvent;
client: Client;
message: DiscordMessageEvent["message"];
messageChannelId: string;
author: User;
sender: DiscordSenderIdentity;

View File

@@ -59,6 +59,7 @@ async function createBaseContext(overrides: Record<string, unknown> = {}) {
timestamp: new Date().toISOString(),
attachments: [],
},
messageChannelId: "c1",
author: {
id: "U1",
username: "alice",
@@ -130,4 +131,25 @@ describe("processDiscordMessage ack reactions", () => {
expect(reactMessageDiscord).toHaveBeenCalledWith("c1", "m1", "👀", { rest: {} });
});
it("uses preflight-resolved messageChannelId when message.channelId is missing", async () => {
const ctx = await createBaseContext({
message: {
id: "m1",
timestamp: new Date().toISOString(),
attachments: [],
},
messageChannelId: "fallback-channel",
shouldRequireMention: true,
effectiveWasMentioned: true,
sender: { label: "user" },
});
// oxlint-disable-next-line typescript/no-explicit-any
await processDiscordMessage(ctx as any);
expect(reactMessageDiscord).toHaveBeenCalledWith("fallback-channel", "m1", "👀", {
rest: {},
});
});
});

View File

@@ -59,6 +59,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
client,
channelInfo,
channelName,
messageChannelId,
isGuildMessage,
isDirectMessage,
isGroupDm,
@@ -108,12 +109,12 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
}),
);
const ackReactionPromise = shouldAckReaction()
? reactMessageDiscord(message.channelId, message.id, ackReaction, {
? reactMessageDiscord(messageChannelId, message.id, ackReaction, {
rest: client.rest,
}).then(
() => true,
(err) => {
logVerbose(`discord react failed for channel ${message.channelId}: ${String(err)}`);
logVerbose(`discord react failed for channel ${messageChannelId}: ${String(err)}`);
return false;
},
)
@@ -123,8 +124,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
? buildDirectLabel(author)
: buildGuildLabel({
guild: data.guild ?? undefined,
channelName: channelName ?? message.channelId,
channelId: message.channelId,
channelName: channelName ?? messageChannelId,
channelId: messageChannelId,
});
const senderLabel = sender.label;
const isForumParent =
@@ -184,7 +185,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
if (shouldIncludeChannelHistory) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: guildHistories,
historyKey: message.channelId,
historyKey: messageChannelId,
limit: historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
@@ -192,7 +193,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
channel: "Discord",
from: fromLabel,
timestamp: entry.timestamp,
body: `${entry.body} [id:${entry.messageId ?? "unknown"} channel:${message.channelId}]`,
body: `${entry.body} [id:${entry.messageId ?? "unknown"} channel:${messageChannelId}]`,
chatType: "channel",
senderLabel: entry.sender,
envelope: envelopeOptions,
@@ -237,13 +238,14 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
const mediaPayload = buildDiscordMediaPayload(mediaList);
const threadKeys = resolveThreadSessionKeys({
baseSessionKey,
threadId: threadChannel ? message.channelId : undefined,
threadId: threadChannel ? messageChannelId : undefined,
parentSessionKey,
useSuffix: false,
});
const replyPlan = await resolveDiscordAutoThreadReplyPlan({
client,
message,
messageChannelId,
isGuildMessage,
channelConfig,
threadChannel,
@@ -260,7 +262,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
const effectiveFrom = isDirectMessage
? `discord:${author.id}`
: (autoThreadContext?.From ?? `discord:channel:${message.channelId}`);
: (autoThreadContext?.From ?? `discord:channel:${messageChannelId}`);
const effectiveTo = autoThreadContext?.To ?? replyTarget;
if (!effectiveTo) {
runtime.error?.(danger("discord: missing reply target"));
@@ -269,7 +271,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
const inboundHistory =
shouldIncludeChannelHistory && historyLimit > 0
? (guildHistories.get(message.channelId) ?? []).map((entry) => ({
? (guildHistories.get(messageChannelId) ?? []).map((entry) => ({
sender: entry.sender,
body: entry.body,
timestamp: entry.timestamp,
@@ -337,13 +339,13 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
if (shouldLogVerbose()) {
const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n");
logVerbose(
`discord inbound: channel=${message.channelId} deliver=${deliverTarget} from=${ctxPayload.From} preview="${preview}"`,
`discord inbound: channel=${messageChannelId} deliver=${deliverTarget} from=${ctxPayload.From} preview="${preview}"`,
);
}
const typingChannelId = deliverTarget.startsWith("channel:")
? deliverTarget.slice("channel:".length)
: message.channelId;
: messageChannelId;
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg,
@@ -412,7 +414,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
if (isGuildMessage) {
clearHistoryEntriesIfEnabled({
historyMap: guildHistories,
historyKey: message.channelId,
historyKey: messageChannelId,
limit: historyLimit,
});
}
@@ -429,7 +431,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
ackReactionPromise,
ackReactionValue: ackReaction,
remove: async () => {
await removeReactionDiscord(message.channelId, message.id, ackReaction, {
await removeReactionDiscord(messageChannelId, message.id, ackReaction, {
rest: client.rest,
});
},
@@ -437,7 +439,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
logAckFailure({
log: logVerbose,
channel: "discord",
target: `${message.channelId}/${message.id}`,
target: `${messageChannelId}/${message.id}`,
error: err,
});
},
@@ -445,7 +447,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
if (isGuildMessage) {
clearHistoryEntriesIfEnabled({
historyMap: guildHistories,
historyKey: message.channelId,
historyKey: messageChannelId,
limit: historyLimit,
});
}

View File

@@ -9,7 +9,7 @@ import {
import { danger } from "../../globals.js";
import { preflightDiscordMessage } from "./message-handler.preflight.js";
import { processDiscordMessage } from "./message-handler.process.js";
import { resolveDiscordMessageText } from "./message-utils.js";
import { resolveDiscordMessageChannelId, resolveDiscordMessageText } from "./message-utils.js";
type DiscordMessageHandlerParams = Omit<
DiscordMessagePreflightParams,
@@ -31,7 +31,10 @@ export function createDiscordMessageHandler(
if (!message || !authorId) {
return null;
}
const channelId = message.channelId;
const channelId = resolveDiscordMessageChannelId({
message,
eventChannelId: entry.data.channel_id,
});
if (!channelId) {
return null;
}

View File

@@ -0,0 +1,38 @@
import type { Message } from "@buape/carbon";
import { describe, expect, it } from "vitest";
import { resolveDiscordMessageChannelId } from "./message-utils.js";
function asMessage(payload: Record<string, unknown>): Message {
return payload as unknown as Message;
}
describe("resolveDiscordMessageChannelId", () => {
it("uses message.channelId when present", () => {
const channelId = resolveDiscordMessageChannelId({
message: asMessage({ channelId: " 123 " }),
});
expect(channelId).toBe("123");
});
it("falls back to message.channel_id", () => {
const channelId = resolveDiscordMessageChannelId({
message: asMessage({ channel_id: " 234 " }),
});
expect(channelId).toBe("234");
});
it("falls back to message.rawData.channel_id", () => {
const channelId = resolveDiscordMessageChannelId({
message: asMessage({ rawData: { channel_id: "456" } }),
});
expect(channelId).toBe("456");
});
it("falls back to eventChannelId and coerces numeric values", () => {
const channelId = resolveDiscordMessageChannelId({
message: asMessage({}),
eventChannelId: 789,
});
expect(channelId).toBe("789");
});
});

View File

@@ -18,6 +18,11 @@ export type DiscordChannelInfo = {
ownerId?: string;
};
type DiscordMessageWithChannelId = Message & {
channel_id?: unknown;
rawData?: { channel_id?: unknown };
};
type DiscordSnapshotAuthor = {
id?: string | null;
username?: string | null;
@@ -48,6 +53,29 @@ export function __resetDiscordChannelInfoCacheForTest() {
DISCORD_CHANNEL_INFO_CACHE.clear();
}
function normalizeDiscordChannelId(value: unknown): string {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" || typeof value === "bigint") {
return String(value).trim();
}
return "";
}
export function resolveDiscordMessageChannelId(params: {
message: Message;
eventChannelId?: string | number | null;
}): string {
const message = params.message as DiscordMessageWithChannelId;
return (
normalizeDiscordChannelId(message.channelId) ||
normalizeDiscordChannelId(message.channel_id) ||
normalizeDiscordChannelId(message.rawData?.channel_id) ||
normalizeDiscordChannelId(params.eventChannelId)
);
}
export async function resolveDiscordChannelInfo(
client: Client,
channelId: string,

View File

@@ -7,7 +7,7 @@ import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-refere
import { logVerbose } from "../../globals.js";
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
import { truncateUtf16Safe } from "../../utils.js";
import { resolveDiscordChannelInfo } from "./message-utils.js";
import { resolveDiscordChannelInfo, resolveDiscordMessageChannelId } from "./message-utils.js";
export type DiscordThreadChannel = {
id: string;
@@ -89,6 +89,7 @@ export function resolveDiscordThreadChannel(params: {
isGuildMessage: boolean;
message: DiscordMessageEvent["message"];
channelInfo: import("./message-utils.js").DiscordChannelInfo | null;
messageChannelId?: string;
}): DiscordThreadChannel | null {
if (!params.isGuildMessage) {
return null;
@@ -107,8 +108,16 @@ export function resolveDiscordThreadChannel(params: {
if (!isDiscordThreadType(channelInfo?.type)) {
return null;
}
const messageChannelId =
params.messageChannelId ||
resolveDiscordMessageChannelId({
message,
});
if (!messageChannelId) {
return null;
}
return {
id: message.channelId,
id: messageChannelId,
name: channelInfo?.name ?? undefined,
parentId: channelInfo?.parentId ?? undefined,
parent: undefined,
@@ -285,6 +294,7 @@ export type DiscordAutoThreadReplyPlan = DiscordReplyDeliveryPlan & {
export async function resolveDiscordAutoThreadReplyPlan(params: {
client: Client;
message: DiscordMessageEvent["message"];
messageChannelId?: string;
isGuildMessage: boolean;
channelConfig?: DiscordChannelConfigResolved | null;
threadChannel?: DiscordThreadChannel | null;
@@ -294,12 +304,19 @@ export async function resolveDiscordAutoThreadReplyPlan(params: {
agentId: string;
channel: string;
}): Promise<DiscordAutoThreadReplyPlan> {
const messageChannelId = (
params.messageChannelId ||
resolveDiscordMessageChannelId({
message: params.message,
})
).trim();
// Prefer the resolved thread channel ID when available so replies stay in-thread.
const targetChannelId = params.threadChannel?.id ?? params.message.channelId;
const targetChannelId = params.threadChannel?.id ?? (messageChannelId || "unknown");
const originalReplyTarget = `channel:${targetChannelId}`;
const createdThreadId = await maybeCreateDiscordAutoThread({
client: params.client,
message: params.message,
messageChannelId: messageChannelId || undefined,
isGuildMessage: params.isGuildMessage,
channelConfig: params.channelConfig,
threadChannel: params.threadChannel,
@@ -317,7 +334,7 @@ export async function resolveDiscordAutoThreadReplyPlan(params: {
? resolveDiscordAutoThreadContext({
agentId: params.agentId,
channel: params.channel,
messageChannelId: params.message.channelId,
messageChannelId,
createdThreadId,
})
: null;
@@ -327,6 +344,7 @@ export async function resolveDiscordAutoThreadReplyPlan(params: {
export async function maybeCreateDiscordAutoThread(params: {
client: Client;
message: DiscordMessageEvent["message"];
messageChannelId?: string;
isGuildMessage: boolean;
channelConfig?: DiscordChannelConfigResolved | null;
threadChannel?: DiscordThreadChannel | null;
@@ -342,13 +360,22 @@ export async function maybeCreateDiscordAutoThread(params: {
if (params.threadChannel) {
return undefined;
}
const messageChannelId = (
params.messageChannelId ||
resolveDiscordMessageChannelId({
message: params.message,
})
).trim();
if (!messageChannelId) {
return undefined;
}
try {
const threadName = sanitizeDiscordThreadName(
params.baseText || params.combinedBody || "Thread",
params.message.id,
);
const created = (await params.client.rest.post(
`${Routes.channelMessage(params.message.channelId, params.message.id)}/threads`,
`${Routes.channelMessage(messageChannelId, params.message.id)}/threads`,
{
body: {
name: threadName,
@@ -360,18 +387,18 @@ export async function maybeCreateDiscordAutoThread(params: {
return createdId || undefined;
} catch (err) {
logVerbose(
`discord: autoThread creation failed for ${params.message.channelId}/${params.message.id}: ${String(err)}`,
`discord: autoThread creation failed for ${messageChannelId}/${params.message.id}: ${String(err)}`,
);
// Race condition: another agent may have already created a thread on this
// message. Re-fetch the message to check for an existing thread.
try {
const msg = (await params.client.rest.get(
Routes.channelMessage(params.message.channelId, params.message.id),
Routes.channelMessage(messageChannelId, params.message.id),
)) as { thread?: { id?: string } };
const existingThreadId = msg?.thread?.id ? String(msg.thread.id) : "";
if (existingThreadId) {
logVerbose(
`discord: autoThread reusing existing thread ${existingThreadId} on ${params.message.channelId}/${params.message.id}`,
`discord: autoThread reusing existing thread ${existingThreadId} on ${messageChannelId}/${params.message.id}`,
);
return existingThreadId;
}

View File

@@ -56,10 +56,20 @@ function resolveSessionKey({ sessionKey, sessionId }: SessionRef) {
return sessionKey ?? sessionId ?? "unknown";
}
function findStateBySessionId(sessionId: string): SessionState | undefined {
for (const state of diagnosticSessionStates.values()) {
if (state.sessionId === sessionId) {
return state;
}
}
return undefined;
}
export function getDiagnosticSessionState(ref: SessionRef): SessionState {
pruneDiagnosticSessionStates();
const key = resolveSessionKey(ref);
const existing = diagnosticSessionStates.get(key);
const existing =
diagnosticSessionStates.get(key) ?? (ref.sessionId && findStateBySessionId(ref.sessionId));
if (existing) {
if (ref.sessionId) {
existing.sessionId = ref.sessionId;

View File

@@ -34,6 +34,18 @@ describe("diagnostic session state pruning", () => {
expect(getDiagnosticSessionStateCountForTest()).toBe(2000);
});
it("reuses keyed session state when later looked up by sessionId", () => {
const keyed = getDiagnosticSessionState({
sessionId: "s1",
sessionKey: "agent:main:discord:channel:c1",
});
const bySessionId = getDiagnosticSessionState({ sessionId: "s1" });
expect(bySessionId).toBe(keyed);
expect(bySessionId.sessionKey).toBe("agent:main:discord:channel:c1");
expect(getDiagnosticSessionStateCountForTest()).toBe(1);
});
});
describe("logger import side effects", () => {

View File

@@ -141,6 +141,17 @@ describe("resolveAgentRoute", () => {
expect(route.matchedBy).toBe("binding.peer");
});
test("coerces numeric peer ids to stable session keys", () => {
const cfg: OpenClawConfig = {};
const route = resolveAgentRoute({
cfg,
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: 1468834856187203680n as unknown as string },
});
expect(route.sessionKey).toBe("agent:main:discord:channel:1468834856187203680");
});
test("guild binding wins over account binding when peer not bound", () => {
const cfg: OpenClawConfig = {
bindings: [

View File

@@ -61,8 +61,14 @@ function normalizeToken(value: string | undefined | null): string {
return (value ?? "").trim().toLowerCase();
}
function normalizeId(value: string | undefined | null): string {
return (value ?? "").trim();
function normalizeId(value: unknown): string {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" || typeof value === "bigint") {
return String(value).trim();
}
return "";
}
function normalizeAccountId(value: string | undefined | null): string {