mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-29 18:12:52 +00:00
Sessions: parse thread suffixes by channel
This commit is contained in:
@@ -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 { parseThreadSessionSuffix } from "../../sessions/session-key-utils.js";
|
||||
|
||||
const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP";
|
||||
const REPLY_SKIP_TOKEN = "REPLY_SKIP";
|
||||
@@ -30,7 +30,9 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget
|
||||
}
|
||||
|
||||
const restJoined = rest.join(":");
|
||||
const { baseSessionKey, threadId } = parseSessionThreadInfo(restJoined);
|
||||
const { baseSessionKey, threadId } = parseThreadSessionSuffix(restJoined, {
|
||||
channelHint: channelRaw,
|
||||
});
|
||||
const id = (baseSessionKey ?? restJoined).trim();
|
||||
|
||||
if (!id) {
|
||||
|
||||
@@ -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): {
|
||||
|
||||
17
src/config/sessions/reset.test.ts
Normal file
17
src/config/sessions/reset.test.ts
Normal file
@@ -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,35 @@ 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session key canonicalization", () => {
|
||||
function expectSessionKeyCanonicalizationCase(params: { run: () => void }) {
|
||||
params.run();
|
||||
|
||||
@@ -4,6 +4,10 @@ export type ParsedAgentSessionKey = {
|
||||
};
|
||||
|
||||
export type SessionKeyChatType = "direct" | "group" | "channel" | "unknown";
|
||||
export type ParsedThreadSessionSuffix = {
|
||||
baseSessionKey: string | undefined;
|
||||
threadId: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse agent-scoped session keys in a canonical, case-insensitive way.
|
||||
@@ -107,26 +111,55 @@ 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;
|
||||
}
|
||||
|
||||
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,
|
||||
options?: { channelHint?: string | null },
|
||||
): ParsedThreadSessionSuffix {
|
||||
const raw = (sessionKey ?? "").trim();
|
||||
if (!raw) {
|
||||
return { baseSessionKey: undefined, threadId: undefined };
|
||||
}
|
||||
|
||||
const channelHint =
|
||||
normalizeThreadSuffixChannelHint(options?.channelHint) ?? inferThreadSuffixChannelHint(raw);
|
||||
const topicIndex = channelHint === "telegram" ? raw.lastIndexOf(":topic:") : -1;
|
||||
const threadIndex = raw.lastIndexOf(":thread:");
|
||||
const markerIndex = Math.max(topicIndex, threadIndex);
|
||||
const marker = topicIndex > threadIndex ? ":topic:" : ":thread:";
|
||||
|
||||
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 resolveThreadParentSessionKey(
|
||||
sessionKey: string | undefined | null,
|
||||
): string | null {
|
||||
const raw = (sessionKey ?? "").trim();
|
||||
if (!raw) {
|
||||
const { baseSessionKey, threadId } = parseThreadSessionSuffix(sessionKey);
|
||||
if (!threadId) {
|
||||
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 parent = baseSessionKey?.trim();
|
||||
if (!parent) {
|
||||
return null;
|
||||
}
|
||||
const parent = raw.slice(0, idx).trim();
|
||||
return parent ? parent : null;
|
||||
return parent;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user