mirror of https://github.com/openclaw/openclaw.git
Sessions: parse thread suffixes by channel (#58100)
Merged via squash.
Prepared head SHA: 2829b9c5b5
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
parent
11590eb6ce
commit
4ea1ca4849
|
|
@ -160,6 +160,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Exec approvals: unwrap `caffeinate` and `sandbox-exec` before persisting allow-always trust so later shell payload changes still require a fresh approval. Thanks @tdjackey and @vincentkoc.
|
||||
- Matrix/DM threads: keep strict unnamed fresh-invite rooms promotable even when Matrix omits the optional direct hint, preserve repair-failed local DM promotions while still revalidating later room metadata, and keep both bound and thread-isolated Matrix sessions reporting the correct route policy. (#58099) Thanks @gumadeiras.
|
||||
- ClawFlow: add a small flow runtime substrate for authoring layers with persisted wait targets and output bags, plus bundled skills/Lobster examples and richer `flows show` / `doctor` recovery hints for multi-task flow state. (#58336) Thanks @mbelinky.
|
||||
- Sessions/Feishu: preserve conversation ids that legitimately embed `:topic:` in shared session helper parsing, while keeping Telegram topic session parsing intact. (#58100) Thanks @gumadeiras.
|
||||
|
||||
## 2026.3.28
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,28 @@ describe("resolveAnnounceTargetFromKey", () => {
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "feishu",
|
||||
source: "test",
|
||||
plugin: {
|
||||
id: "feishu",
|
||||
meta: {
|
||||
id: "feishu",
|
||||
label: "Feishu",
|
||||
selectionLabel: "Feishu",
|
||||
docsPath: "/channels/feishu",
|
||||
blurb: "Feishu test stub.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct", "group", "thread"] },
|
||||
messaging: {
|
||||
normalizeTarget: (raw: string) => raw.replace(/^group:/, ""),
|
||||
},
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
|
@ -141,4 +163,16 @@ describe("resolveAnnounceTargetFromKey", () => {
|
|||
threadId: "$AbC123:example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves feishu conversation ids that embed :topic: in the base id", () => {
|
||||
expect(
|
||||
resolveAnnounceTargetFromKey(
|
||||
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
||||
),
|
||||
).toEqual({
|
||||
channel: "feishu",
|
||||
to: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
||||
threadId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 { parseSessionThreadInfo } from "../../config/sessions/delivery-info.js";
|
||||
import { parseSessionConversationRef } from "../../sessions/session-key-utils.js";
|
||||
|
||||
const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP";
|
||||
const REPLY_SKIP_TOKEN = "REPLY_SKIP";
|
||||
|
|
@ -19,40 +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 } = parseSessionThreadInfo(restJoined);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,15 @@ describe("extractDeliveryInfo", () => {
|
|||
baseSessionKey: "agent:main:matrix:channel:!room:example.org",
|
||||
threadId: "$AbC123:example.org",
|
||||
});
|
||||
expect(
|
||||
parseSessionThreadInfo(
|
||||
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
||||
),
|
||||
).toEqual({
|
||||
baseSessionKey:
|
||||
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
||||
threadId: undefined,
|
||||
});
|
||||
expect(parseSessionThreadInfo("agent:main:telegram:dm:user-1")).toEqual({
|
||||
baseSessionKey: "agent:main:telegram:dm:user-1",
|
||||
threadId: undefined,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { parseThreadSessionSuffix } from "../../sessions/session-key-utils.js";
|
||||
import { loadConfig } from "../io.js";
|
||||
import { resolveStorePath } from "./paths.js";
|
||||
import { loadSessionStore } from "./store.js";
|
||||
|
|
@ -10,19 +11,7 @@ export function parseSessionThreadInfo(sessionKey: string | undefined): {
|
|||
baseSessionKey: string | undefined;
|
||||
threadId: string | undefined;
|
||||
} {
|
||||
if (!sessionKey) {
|
||||
return { baseSessionKey: undefined, threadId: undefined };
|
||||
}
|
||||
const topicIndex = sessionKey.lastIndexOf(":topic:");
|
||||
const threadIndex = sessionKey.lastIndexOf(":thread:");
|
||||
const markerIndex = Math.max(topicIndex, threadIndex);
|
||||
const marker = topicIndex > threadIndex ? ":topic:" : ":thread:";
|
||||
|
||||
const baseSessionKey = markerIndex === -1 ? sessionKey : sessionKey.slice(0, markerIndex);
|
||||
const threadIdRaw =
|
||||
markerIndex === -1 ? undefined : sessionKey.slice(markerIndex + marker.length);
|
||||
const threadId = threadIdRaw?.trim() || undefined;
|
||||
return { baseSessionKey, threadId };
|
||||
return parseThreadSessionSuffix(sessionKey);
|
||||
}
|
||||
|
||||
export function extractDeliveryInfo(sessionKey: string | undefined): {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { isThreadSessionKey, resolveSessionResetType } from "./reset.js";
|
||||
|
||||
describe("session reset thread detection", () => {
|
||||
it("does not treat feishu conversation ids with embedded :topic: as thread suffixes", () => {
|
||||
const sessionKey =
|
||||
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user";
|
||||
expect(isThreadSessionKey(sessionKey)).toBe(false);
|
||||
expect(resolveSessionResetType({ sessionKey })).toBe("group");
|
||||
});
|
||||
|
||||
it("still treats telegram :topic: suffixes as thread sessions", () => {
|
||||
const sessionKey = "agent:main:telegram:group:-100123:topic:77";
|
||||
expect(isThreadSessionKey(sessionKey)).toBe(true);
|
||||
expect(resolveSessionResetType({ sessionKey })).toBe("thread");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { parseThreadSessionSuffix } from "../../sessions/session-key-utils.js";
|
||||
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||
import type { SessionConfig, SessionResetConfig } from "../types.base.js";
|
||||
import { DEFAULT_IDLE_MINUTES } from "./types.js";
|
||||
|
|
@ -20,15 +21,10 @@ export type SessionFreshness = {
|
|||
export const DEFAULT_RESET_MODE: SessionResetMode = "daily";
|
||||
export const DEFAULT_RESET_AT_HOUR = 4;
|
||||
|
||||
const THREAD_SESSION_MARKERS = [":thread:", ":topic:"];
|
||||
const GROUP_SESSION_MARKERS = [":group:", ":channel:"];
|
||||
|
||||
export function isThreadSessionKey(sessionKey?: string | null): boolean {
|
||||
const normalized = (sessionKey ?? "").toLowerCase();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return THREAD_SESSION_MARKERS.some((marker) => normalized.includes(marker));
|
||||
return Boolean(parseThreadSessionSuffix(sessionKey).threadId);
|
||||
}
|
||||
|
||||
export function resolveSessionResetType(params: {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import {
|
|||
deriveSessionChatType,
|
||||
getSubagentDepth,
|
||||
isCronSessionKey,
|
||||
parseThreadSessionSuffix,
|
||||
resolveThreadParentSessionKey,
|
||||
} from "../sessions/session-key-utils.js";
|
||||
import {
|
||||
classifySessionKeyShape,
|
||||
|
|
@ -84,6 +86,48 @@ describe("deriveSessionChatType", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("thread session suffix parsing", () => {
|
||||
it("preserves feishu conversation ids that embed :topic: in the base id", () => {
|
||||
expect(
|
||||
parseThreadSessionSuffix(
|
||||
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
||||
),
|
||||
).toEqual({
|
||||
baseSessionKey:
|
||||
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
||||
threadId: undefined,
|
||||
});
|
||||
expect(
|
||||
resolveThreadParentSessionKey(
|
||||
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("still parses telegram topic session suffixes", () => {
|
||||
expect(parseThreadSessionSuffix("agent:main:telegram:group:-100123:topic:77")).toEqual({
|
||||
baseSessionKey: "agent:main:telegram:group:-100123",
|
||||
threadId: "77",
|
||||
});
|
||||
expect(resolveThreadParentSessionKey("agent:main:telegram:group:-100123:topic:77")).toBe(
|
||||
"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", () => {
|
||||
function expectSessionKeyCanonicalizationCase(params: { run: () => void }) {
|
||||
params.run();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,17 @@ export type ParsedAgentSessionKey = {
|
|||
};
|
||||
|
||||
export type SessionKeyChatType = "direct" | "group" | "channel" | "unknown";
|
||||
export type ParsedThreadSessionSuffix = {
|
||||
baseSessionKey: string | undefined;
|
||||
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.
|
||||
|
|
@ -107,26 +118,93 @@ export function isAcpSessionKey(sessionKey: string | undefined | null): boolean
|
|||
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("acp:"));
|
||||
}
|
||||
|
||||
const THREAD_SESSION_MARKERS = [":thread:", ":topic:"];
|
||||
function normalizeThreadSuffixChannelHint(value: string | undefined | null): string | undefined {
|
||||
const trimmed = (value ?? "").trim().toLowerCase();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
export function resolveThreadParentSessionKey(
|
||||
function inferThreadSuffixChannelHint(sessionKey: string): string | undefined {
|
||||
const parts = sessionKey.split(":").filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if ((parts[0] ?? "").trim().toLowerCase() === "agent") {
|
||||
return normalizeThreadSuffixChannelHint(parts[2]);
|
||||
}
|
||||
return normalizeThreadSuffixChannelHint(parts[0]);
|
||||
}
|
||||
|
||||
export function parseThreadSessionSuffix(
|
||||
sessionKey: string | undefined | null,
|
||||
): string | null {
|
||||
options?: { channelHint?: string | null },
|
||||
): ParsedThreadSessionSuffix {
|
||||
const raw = (sessionKey ?? "").trim();
|
||||
if (!raw) {
|
||||
return { baseSessionKey: undefined, threadId: undefined };
|
||||
}
|
||||
|
||||
const channelHint =
|
||||
normalizeThreadSuffixChannelHint(options?.channelHint) ?? inferThreadSuffixChannelHint(raw);
|
||||
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 ? topicMarker : threadMarker;
|
||||
|
||||
const baseSessionKey = markerIndex === -1 ? raw : raw.slice(0, markerIndex);
|
||||
const threadIdRaw = markerIndex === -1 ? undefined : raw.slice(markerIndex + marker.length);
|
||||
const threadId = threadIdRaw?.trim() || undefined;
|
||||
|
||||
return { baseSessionKey, threadId };
|
||||
}
|
||||
|
||||
export function parseSessionConversationRef(
|
||||
sessionKey: string | undefined | null,
|
||||
): ParsedSessionConversationRef | null {
|
||||
const raw = (sessionKey ?? "").trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const normalized = raw.toLowerCase();
|
||||
let idx = -1;
|
||||
for (const marker of THREAD_SESSION_MARKERS) {
|
||||
const candidate = normalized.lastIndexOf(marker);
|
||||
if (candidate > idx) {
|
||||
idx = candidate;
|
||||
}
|
||||
}
|
||||
if (idx <= 0) {
|
||||
|
||||
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 parent = raw.slice(0, idx).trim();
|
||||
return parent ? parent : 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 {
|
||||
const { baseSessionKey, threadId } = parseThreadSessionSuffix(sessionKey);
|
||||
if (!threadId) {
|
||||
return null;
|
||||
}
|
||||
const parent = baseSessionKey?.trim();
|
||||
if (!parent) {
|
||||
return null;
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue