fix: tighten session conversation parsing

This commit is contained in:
Gustavo Madeira Santana 2026-03-31 08:33:17 -04:00
parent e1e302faac
commit 2829b9c5b5
No known key found for this signature in database
5 changed files with 156 additions and 54 deletions

View File

@ -4,7 +4,7 @@ import {
} from "../../channels/plugins/index.js";
import { normalizeChannelId as normalizeChatChannelId } from "../../channels/registry.js";
import type { OpenClawConfig } from "../../config/config.js";
import { parseThreadSessionSuffix } from "../../sessions/session-key-utils.js";
import { parseSessionConversationRef } from "../../sessions/session-key-utils.js";
const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP";
const REPLY_SKIP_TOKEN = "REPLY_SKIP";
@ -19,42 +19,25 @@ export type AnnounceTarget = {
};
export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget | null {
const rawParts = sessionKey.split(":").filter(Boolean);
const parts = rawParts.length >= 3 && rawParts[0] === "agent" ? rawParts.slice(2) : rawParts;
if (parts.length < 3) {
const parsed = parseSessionConversationRef(sessionKey);
if (!parsed) {
return null;
}
const [channelRaw, kind, ...rest] = parts;
if (kind !== "group" && kind !== "channel") {
return null;
}
const restJoined = rest.join(":");
const { baseSessionKey, threadId } = parseThreadSessionSuffix(restJoined, {
channelHint: channelRaw,
});
const id = (baseSessionKey ?? restJoined).trim();
if (!id) {
return null;
}
if (!channelRaw) {
return null;
}
const normalizedChannel = normalizeAnyChannelId(channelRaw) ?? normalizeChatChannelId(channelRaw);
const channel = normalizedChannel ?? channelRaw.toLowerCase();
const normalizedChannel =
normalizeAnyChannelId(parsed.channel) ?? normalizeChatChannelId(parsed.channel);
const channel = normalizedChannel ?? parsed.channel;
const plugin = normalizedChannel ? getChannelPlugin(normalizedChannel) : null;
const genericTarget = kind === "channel" ? `channel:${id}` : `group:${id}`;
const genericTarget = parsed.kind === "channel" ? `channel:${parsed.id}` : `group:${parsed.id}`;
const normalized =
plugin?.messaging?.resolveSessionTarget?.({
kind,
id,
threadId,
kind: parsed.kind,
id: parsed.id,
threadId: parsed.threadId,
}) ?? plugin?.messaging?.normalizeTarget?.(genericTarget);
return {
channel,
to: normalized ?? (normalizedChannel ? genericTarget : id),
threadId,
to: normalized ?? (normalizedChannel ? genericTarget : parsed.id),
threadId: parsed.threadId,
};
}

View File

@ -10,13 +10,13 @@ describe("resolveChannelModelOverride", () => {
cfg: {
channels: {
modelByChannel: {
"demo-group": {
telegram: {
"-100123": "demo-provider/demo-parent-model",
},
},
},
} as unknown as OpenClawConfig,
channel: "demo-group",
channel: "telegram",
groupId: "-100123:topic:99",
},
expected: { model: "demo-provider/demo-parent-model", matchKey: "-100123" },
@ -27,14 +27,14 @@ describe("resolveChannelModelOverride", () => {
cfg: {
channels: {
modelByChannel: {
"demo-group": {
telegram: {
"-100123": "demo-provider/demo-parent-model",
"-100123:topic:99": "demo-provider/demo-topic-model",
},
},
},
} as unknown as OpenClawConfig,
channel: "demo-group",
channel: "telegram",
groupId: "-100123:topic:99",
},
expected: { model: "demo-provider/demo-topic-model", matchKey: "-100123:topic:99" },
@ -57,6 +57,48 @@ describe("resolveChannelModelOverride", () => {
},
expected: { model: "demo-provider/demo-parent-model", matchKey: "123" },
},
{
name: "preserves feishu topic ids for direct matches",
input: {
cfg: {
channels: {
modelByChannel: {
feishu: {
"oc_group_chat:topic:om_topic_root": "demo-provider/demo-feishu-topic-model",
},
},
},
} as unknown as OpenClawConfig,
channel: "feishu",
groupId: "oc_group_chat:topic:om_topic_root",
},
expected: {
model: "demo-provider/demo-feishu-topic-model",
matchKey: "oc_group_chat:topic:om_topic_root",
},
},
{
name: "preserves feishu topic ids when falling back from parent session key",
input: {
cfg: {
channels: {
modelByChannel: {
feishu: {
"oc_group_chat:topic:om_topic_root": "demo-provider/demo-feishu-topic-model",
},
},
},
} as unknown as OpenClawConfig,
channel: "feishu",
groupId: "unrelated",
parentSessionKey:
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
},
expected: {
model: "demo-provider/demo-feishu-topic-model",
matchKey: "oc_group_chat:topic:om_topic_root",
},
},
] as const)("$name", ({ input, expected }) => {
const resolved = resolveChannelModelOverride(input);
expect(resolved?.model).toBe(expected.model);

View File

@ -1,5 +1,8 @@
import type { OpenClawConfig } from "../config/config.js";
import { parseAgentSessionKey } from "../sessions/session-key-utils.js";
import {
parseSessionConversationRef,
parseThreadSessionSuffix,
} from "../sessions/session-key-utils.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
import {
buildChannelKeyCandidates,
@ -8,8 +11,6 @@ import {
type ChannelMatchSource,
} from "./channel-config.js";
const THREAD_SUFFIX_REGEX = /:(?:thread|topic):[^:]+$/i;
export type ChannelModelOverride = {
channel: string;
model: string;
@ -44,38 +45,51 @@ function resolveProviderEntry(
);
}
function resolveParentGroupId(groupId: string | undefined): string | undefined {
function resolveParentGroupId(
groupId: string | undefined,
channelHint?: string | null,
): string | undefined {
const raw = groupId?.trim();
if (!raw || !THREAD_SUFFIX_REGEX.test(raw)) {
if (!raw) {
return undefined;
}
const parent = raw.replace(THREAD_SUFFIX_REGEX, "").trim();
const parent = parseThreadSessionSuffix(raw, { channelHint }).baseSessionKey?.trim();
return parent && parent !== raw ? parent : undefined;
}
function resolveSenderScopedParentGroupId(groupId: string | undefined): string | undefined {
const raw = groupId?.trim();
if (!raw) {
return undefined;
}
const parent = raw.replace(/:sender:[^:]+$/i, "").trim();
return parent && parent !== raw ? parent : undefined;
}
function resolveGroupIdFromSessionKey(sessionKey?: string | null): string | undefined {
const raw = sessionKey?.trim();
if (!raw) {
return undefined;
}
const parsed = parseAgentSessionKey(raw);
const candidate = parsed?.rest ?? raw;
const match = candidate.match(/(?:^|:)(?:group|channel):([^:]+)(?::|$)/i);
const id = match?.[1]?.trim();
return id || undefined;
return parseSessionConversationRef(sessionKey)?.id;
}
function buildChannelCandidates(
params: Pick<
ChannelModelOverrideParams,
"groupId" | "groupChannel" | "groupSubject" | "parentSessionKey"
"channel" | "groupId" | "groupChannel" | "groupSubject" | "parentSessionKey"
>,
) {
const normalizedChannel =
normalizeMessageChannel(params.channel ?? "") ?? params.channel?.trim().toLowerCase();
const groupId = params.groupId?.trim();
const parentGroupId = resolveParentGroupId(groupId);
const senderParentGroupId = resolveSenderScopedParentGroupId(groupId);
const parentGroupId = resolveParentGroupId(groupId, normalizedChannel);
const parentGroupIdFromSession = resolveGroupIdFromSessionKey(params.parentSessionKey);
const senderParentGroupIdFromSession = resolveSenderScopedParentGroupId(parentGroupIdFromSession);
const parentGroupIdResolved =
resolveParentGroupId(parentGroupIdFromSession) ?? parentGroupIdFromSession;
resolveParentGroupId(parentGroupIdFromSession, normalizedChannel) ?? parentGroupIdFromSession;
const senderParentResolved =
resolveParentGroupId(senderParentGroupId, normalizedChannel) ?? senderParentGroupId;
const senderParentFromSessionResolved =
resolveParentGroupId(senderParentGroupIdFromSession, normalizedChannel) ??
senderParentGroupIdFromSession;
const groupChannel = params.groupChannel?.trim();
const groupSubject = params.groupSubject?.trim();
const channelBare = groupChannel ? groupChannel.replace(/^#/, "") : undefined;
@ -85,7 +99,12 @@ function buildChannelCandidates(
return buildChannelKeyCandidates(
groupId,
senderParentGroupId,
senderParentResolved,
parentGroupId,
parentGroupIdFromSession,
senderParentGroupIdFromSession,
senderParentFromSessionResolved,
parentGroupIdResolved,
groupChannel,
channelBare,

View File

@ -113,6 +113,19 @@ describe("thread session suffix parsing", () => {
"agent:main:telegram:group:-100123",
);
});
it("parses mixed-case suffix markers without lowercasing the stored key", () => {
expect(
parseThreadSessionSuffix("agent:main:slack:channel:General:Thread:1699999999.0001"),
).toEqual({
baseSessionKey: "agent:main:slack:channel:General",
threadId: "1699999999.0001",
});
expect(parseThreadSessionSuffix("agent:main:telegram:group:-100123:Topic:77")).toEqual({
baseSessionKey: "agent:main:telegram:group:-100123",
threadId: "77",
});
});
});
describe("session key canonicalization", () => {

View File

@ -9,6 +9,13 @@ export type ParsedThreadSessionSuffix = {
threadId: string | undefined;
};
export type ParsedSessionConversationRef = {
channel: string;
kind: "group" | "channel";
id: string;
threadId: string | undefined;
};
/**
* Parse agent-scoped session keys in a canonical, case-insensitive way.
* Returned values are normalized to lowercase for stable comparisons/routing.
@ -138,10 +145,13 @@ export function parseThreadSessionSuffix(
const channelHint =
normalizeThreadSuffixChannelHint(options?.channelHint) ?? inferThreadSuffixChannelHint(raw);
const topicIndex = channelHint === "telegram" ? raw.lastIndexOf(":topic:") : -1;
const threadIndex = raw.lastIndexOf(":thread:");
const lowerRaw = raw.toLowerCase();
const topicMarker = ":topic:";
const threadMarker = ":thread:";
const topicIndex = channelHint === "telegram" ? lowerRaw.lastIndexOf(topicMarker) : -1;
const threadIndex = lowerRaw.lastIndexOf(threadMarker);
const markerIndex = Math.max(topicIndex, threadIndex);
const marker = topicIndex > threadIndex ? ":topic:" : ":thread:";
const marker = topicIndex > threadIndex ? topicMarker : threadMarker;
const baseSessionKey = markerIndex === -1 ? raw : raw.slice(0, markerIndex);
const threadIdRaw = markerIndex === -1 ? undefined : raw.slice(markerIndex + marker.length);
@ -150,6 +160,41 @@ export function parseThreadSessionSuffix(
return { baseSessionKey, threadId };
}
export function parseSessionConversationRef(
sessionKey: string | undefined | null,
): ParsedSessionConversationRef | null {
const raw = (sessionKey ?? "").trim();
if (!raw) {
return null;
}
const rawParts = raw.split(":").filter(Boolean);
const parts =
rawParts.length >= 3 && rawParts[0]?.trim().toLowerCase() === "agent"
? rawParts.slice(2)
: rawParts;
if (parts.length < 3) {
return null;
}
const channel = normalizeThreadSuffixChannelHint(parts[0]);
const kind = parts[1]?.trim().toLowerCase();
if (!channel || (kind !== "group" && kind !== "channel")) {
return null;
}
const joined = parts.slice(2).join(":");
const { baseSessionKey, threadId } = parseThreadSessionSuffix(joined, {
channelHint: channel,
});
const id = (baseSessionKey ?? joined).trim();
if (!id) {
return null;
}
return { channel, kind, id, threadId };
}
export function resolveThreadParentSessionKey(
sessionKey: string | undefined | null,
): string | null {