mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 03:03:24 -04:00
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -34,6 +34,7 @@ export type DiscordMessagePreflightContext = {
|
||||
data: DiscordMessageEvent;
|
||||
client: Client;
|
||||
message: DiscordMessageEvent["message"];
|
||||
messageChannelId: string;
|
||||
author: User;
|
||||
sender: DiscordSenderIdentity;
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
38
src/discord/monitor/message-utils.test.ts
Normal file
38
src/discord/monitor/message-utils.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user