refactor: prune legacy group targets

This commit is contained in:
Peter Steinberger
2026-01-17 09:01:36 +00:00
parent ae6792522d
commit bbb71c9198
15 changed files with 65 additions and 37 deletions

View File

@@ -40,43 +40,43 @@ describe("history helpers", () => {
appendHistoryEntry({ appendHistoryEntry({
historyMap, historyMap,
historyKey: "room", historyKey: "group",
limit: 2, limit: 2,
entry: { sender: "A", body: "one" }, entry: { sender: "A", body: "one" },
}); });
appendHistoryEntry({ appendHistoryEntry({
historyMap, historyMap,
historyKey: "room", historyKey: "group",
limit: 2, limit: 2,
entry: { sender: "B", body: "two" }, entry: { sender: "B", body: "two" },
}); });
appendHistoryEntry({ appendHistoryEntry({
historyMap, historyMap,
historyKey: "room", historyKey: "group",
limit: 2, limit: 2,
entry: { sender: "C", body: "three" }, entry: { sender: "C", body: "three" },
}); });
expect(historyMap.get("room")?.map((entry) => entry.body)).toEqual(["two", "three"]); expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two", "three"]);
}); });
it("builds context from map and appends entry", () => { it("builds context from map and appends entry", () => {
const historyMap = new Map<string, { sender: string; body: string }[]>(); const historyMap = new Map<string, { sender: string; body: string }[]>();
historyMap.set("room", [ historyMap.set("group", [
{ sender: "A", body: "one" }, { sender: "A", body: "one" },
{ sender: "B", body: "two" }, { sender: "B", body: "two" },
]); ]);
const result = buildHistoryContextFromMap({ const result = buildHistoryContextFromMap({
historyMap, historyMap,
historyKey: "room", historyKey: "group",
limit: 3, limit: 3,
entry: { sender: "C", body: "three" }, entry: { sender: "C", body: "three" },
currentMessage: "current", currentMessage: "current",
formatEntry: (entry) => `${entry.sender}: ${entry.body}`, formatEntry: (entry) => `${entry.sender}: ${entry.body}`,
}); });
expect(historyMap.get("room")?.map((entry) => entry.body)).toEqual(["one", "two", "three"]); expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two", "three"]);
expect(result).toContain(HISTORY_CONTEXT_MARKER); expect(result).toContain(HISTORY_CONTEXT_MARKER);
expect(result).toContain("A: one"); expect(result).toContain("A: one");
expect(result).toContain("B: two"); expect(result).toContain("B: two");
@@ -85,20 +85,20 @@ describe("history helpers", () => {
it("builds context from pending map without appending", () => { it("builds context from pending map without appending", () => {
const historyMap = new Map<string, { sender: string; body: string }[]>(); const historyMap = new Map<string, { sender: string; body: string }[]>();
historyMap.set("room", [ historyMap.set("group", [
{ sender: "A", body: "one" }, { sender: "A", body: "one" },
{ sender: "B", body: "two" }, { sender: "B", body: "two" },
]); ]);
const result = buildPendingHistoryContextFromMap({ const result = buildPendingHistoryContextFromMap({
historyMap, historyMap,
historyKey: "room", historyKey: "group",
limit: 3, limit: 3,
currentMessage: "current", currentMessage: "current",
formatEntry: (entry) => `${entry.sender}: ${entry.body}`, formatEntry: (entry) => `${entry.sender}: ${entry.body}`,
}); });
expect(historyMap.get("room")?.map((entry) => entry.body)).toEqual(["one", "two"]); expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]);
expect(result).toContain(HISTORY_CONTEXT_MARKER); expect(result).toContain(HISTORY_CONTEXT_MARKER);
expect(result).toContain("A: one"); expect(result).toContain("A: one");
expect(result).toContain("B: two"); expect(result).toContain("B: two");

View File

@@ -229,7 +229,7 @@ export async function initSessionState(params: {
channel: baseEntry?.channel, channel: baseEntry?.channel,
groupId: baseEntry?.groupId, groupId: baseEntry?.groupId,
subject: baseEntry?.subject, subject: baseEntry?.subject,
room: baseEntry?.room, groupChannel: baseEntry?.groupChannel,
space: baseEntry?.space, space: baseEntry?.space,
deliveryContext: deliveryFields.deliveryContext, deliveryContext: deliveryFields.deliveryContext,
// Track originating channel for subagent announce routing. // Track originating channel for subagent announce routing.
@@ -247,24 +247,24 @@ export async function initSessionState(params: {
normalizedChannel && normalizedChannel &&
getChannelDock(normalizedChannel)?.capabilities.chatTypes.includes("channel"), getChannelDock(normalizedChannel)?.capabilities.chatTypes.includes("channel"),
); );
const nextRoom = const nextGroupChannel =
explicitChannel ?? explicitChannel ??
((groupResolution.chatType === "channel" || isChannelProvider) && ((groupResolution.chatType === "channel" || isChannelProvider) &&
subject && subject &&
subject.startsWith("#") subject.startsWith("#")
? subject ? subject
: undefined); : undefined);
const nextSubject = nextRoom ? undefined : subject; const nextSubject = nextGroupChannel ? undefined : subject;
sessionEntry.chatType = groupResolution.chatType ?? "group"; sessionEntry.chatType = groupResolution.chatType ?? "group";
sessionEntry.channel = channel; sessionEntry.channel = channel;
sessionEntry.groupId = groupResolution.id; sessionEntry.groupId = groupResolution.id;
if (nextSubject) sessionEntry.subject = nextSubject; if (nextSubject) sessionEntry.subject = nextSubject;
if (nextRoom) sessionEntry.room = nextRoom; if (nextGroupChannel) sessionEntry.groupChannel = nextGroupChannel;
if (space) sessionEntry.space = space; if (space) sessionEntry.space = space;
sessionEntry.displayName = buildGroupDisplayName({ sessionEntry.displayName = buildGroupDisplayName({
provider: sessionEntry.channel, provider: sessionEntry.channel,
subject: sessionEntry.subject, subject: sessionEntry.subject,
room: sessionEntry.room, groupChannel: sessionEntry.groupChannel,
space: sessionEntry.space, space: sessionEntry.space,
id: groupResolution.id, id: groupResolution.id,
key: sessionKey, key: sessionKey,

View File

@@ -55,7 +55,7 @@ describe("sessions", () => {
expect( expect(
buildGroupDisplayName({ buildGroupDisplayName({
provider: "discord", provider: "discord",
room: "#general", groupChannel: "#general",
space: "friends-of-clawd", space: "friends-of-clawd",
id: "123", id: "123",
key: "discord:group:123", key: "discord:group:123",

View File

@@ -22,26 +22,26 @@ function shortenGroupId(value?: string) {
export function buildGroupDisplayName(params: { export function buildGroupDisplayName(params: {
provider?: string; provider?: string;
subject?: string; subject?: string;
room?: string; groupChannel?: string;
space?: string; space?: string;
id?: string; id?: string;
key: string; key: string;
}) { }) {
const providerKey = (params.provider?.trim().toLowerCase() || "group").trim(); const providerKey = (params.provider?.trim().toLowerCase() || "group").trim();
const room = params.room?.trim(); const groupChannel = params.groupChannel?.trim();
const space = params.space?.trim(); const space = params.space?.trim();
const subject = params.subject?.trim(); const subject = params.subject?.trim();
const detail = const detail =
(room && space (groupChannel && space
? `${space}${room.startsWith("#") ? "" : "#"}${room}` ? `${space}${groupChannel.startsWith("#") ? "" : "#"}${groupChannel}`
: room || subject || space || "") || ""; : groupChannel || subject || space || "") || "";
const fallbackId = params.id?.trim() || params.key; const fallbackId = params.id?.trim() || params.key;
const rawLabel = detail || fallbackId; const rawLabel = detail || fallbackId;
let token = normalizeGroupLabel(rawLabel); let token = normalizeGroupLabel(rawLabel);
if (!token) { if (!token) {
token = normalizeGroupLabel(shortenGroupId(rawLabel)); token = normalizeGroupLabel(shortenGroupId(rawLabel));
} }
if (!params.room && token.startsWith("#")) { if (!params.groupChannel && token.startsWith("#")) {
token = token.replace(/^#+/, ""); token = token.replace(/^#+/, "");
} }
if (token && !/^[@#]/.test(token) && !token.startsWith("g-") && !token.includes("#")) { if (token && !/^[@#]/.test(token) && !token.startsWith("g-") && !token.includes("#")) {

View File

@@ -130,6 +130,14 @@ export function loadSessionStore(
rec.lastChannel = rec.lastProvider; rec.lastChannel = rec.lastProvider;
delete rec.lastProvider; delete rec.lastProvider;
} }
// Best-effort migration: legacy `room` field → `groupChannel` (keep value, prune old key).
if (typeof rec.groupChannel !== "string" && typeof rec.room === "string") {
rec.groupChannel = rec.room;
delete rec.room;
} else if ("room" in rec) {
delete rec.room;
}
} }
// Cache the result if caching is enabled // Cache the result if caching is enabled

View File

@@ -67,7 +67,7 @@ export type SessionEntry = {
channel?: string; channel?: string;
groupId?: string; groupId?: string;
subject?: string; subject?: string;
room?: string; groupChannel?: string;
space?: string; space?: string;
deliveryContext?: DeliveryContext; deliveryContext?: DeliveryContext;
lastChannel?: SessionChannelId; lastChannel?: SessionChannelId;

View File

@@ -209,7 +209,7 @@ export const handleSessionsBridgeMethods: BridgeMethodHandler = async (
chatType: entry?.chatType, chatType: entry?.chatType,
channel: entry?.channel, channel: entry?.channel,
subject: entry?.subject, subject: entry?.subject,
room: entry?.room, groupChannel: entry?.groupChannel,
space: entry?.space, space: entry?.space,
lastChannel: entry?.lastChannel, lastChannel: entry?.lastChannel,
lastTo: entry?.lastTo, lastTo: entry?.lastTo,

View File

@@ -378,7 +378,7 @@ export function listSessionsFromStore(params: {
const parsed = parseGroupKey(key); const parsed = parseGroupKey(key);
const channel = entry?.channel ?? parsed?.channel; const channel = entry?.channel ?? parsed?.channel;
const subject = entry?.subject; const subject = entry?.subject;
const room = entry?.room; const groupChannel = entry?.groupChannel;
const space = entry?.space; const space = entry?.space;
const id = parsed?.id; const id = parsed?.id;
const displayName = const displayName =
@@ -387,7 +387,7 @@ export function listSessionsFromStore(params: {
? buildGroupDisplayName({ ? buildGroupDisplayName({
provider: channel, provider: channel,
subject, subject,
room, groupChannel,
space, space,
id, id,
key, key,
@@ -401,7 +401,7 @@ export function listSessionsFromStore(params: {
displayName, displayName,
channel, channel,
subject, subject,
room, groupChannel,
space, space,
chatType: entry?.chatType, chatType: entry?.chatType,
updatedAt, updatedAt,

View File

@@ -15,7 +15,7 @@ export type GatewaySessionRow = {
displayName?: string; displayName?: string;
channel?: string; channel?: string;
subject?: string; subject?: string;
room?: string; groupChannel?: string;
space?: string; space?: string;
chatType?: NormalizedChatType; chatType?: NormalizedChatType;
updatedAt: number | null; updatedAt: number | null;

View File

@@ -61,7 +61,7 @@ function normalizeQuery(value: string): string {
function stripTargetPrefixes(value: string): string { function stripTargetPrefixes(value: string): string {
return value return value
.replace(/^(channel|group|user):/i, "") .replace(/^(channel|user):/i, "")
.replace(/^[@#]/, "") .replace(/^[@#]/, "")
.trim(); .trim();
} }
@@ -88,7 +88,7 @@ export function formatTargetDisplay(params: {
params.kind ?? params.kind ??
(lowered.startsWith("user:") (lowered.startsWith("user:")
? "user" ? "user"
: lowered.startsWith("channel:") || lowered.startsWith("group:") : lowered.startsWith("channel:")
? "group" ? "group"
: undefined); : undefined);
@@ -103,8 +103,8 @@ export function formatTargetDisplay(params: {
if (trimmedTarget.startsWith("#") || trimmedTarget.startsWith("@")) return trimmedTarget; if (trimmedTarget.startsWith("#") || trimmedTarget.startsWith("@")) return trimmedTarget;
const withoutPrefix = trimmedTarget.replace(/^telegram:/i, ""); const withoutPrefix = trimmedTarget.replace(/^telegram:/i, "");
if (/^(channel|group):/i.test(withoutPrefix)) { if (/^channel:/i.test(withoutPrefix)) {
return `#${withoutPrefix.replace(/^(channel|group):/i, "")}`; return `#${withoutPrefix.replace(/^channel:/i, "")}`;
} }
if (/^user:/i.test(withoutPrefix)) { if (/^user:/i.test(withoutPrefix)) {
return `@${withoutPrefix.replace(/^user:/i, "")}`; return `@${withoutPrefix.replace(/^user:/i, "")}`;
@@ -126,7 +126,7 @@ function detectTargetKind(raw: string, preferred?: TargetResolveKind): TargetRes
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return "group"; if (!trimmed) return "group";
if (trimmed.startsWith("@") || /^<@!?/.test(trimmed) || /^user:/i.test(trimmed)) return "user"; if (trimmed.startsWith("@") || /^<@!?/.test(trimmed) || /^user:/i.test(trimmed)) return "user";
if (trimmed.startsWith("#") || /^channel:/i.test(trimmed) || /^group:/i.test(trimmed)) { if (trimmed.startsWith("#") || /^channel:/i.test(trimmed)) {
return "group"; return "group";
} }
return "group"; return "group";

View File

@@ -131,7 +131,13 @@ function normalizeSessionEntry(entry: SessionEntryLike): SessionEntry | null {
typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt) typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt)
? entry.updatedAt ? entry.updatedAt
: Date.now(); : Date.now();
return { ...(entry as unknown as SessionEntry), sessionId, updatedAt }; const normalized = { ...(entry as unknown as SessionEntry), sessionId, updatedAt };
const rec = normalized as unknown as Record<string, unknown>;
if (typeof rec.groupChannel !== "string" && typeof rec.room === "string") {
rec.groupChannel = rec.room;
}
delete rec.room;
return normalized;
} }
function emptyDirOrMissing(dir: string): boolean { function emptyDirOrMissing(dir: string): boolean {

View File

@@ -21,7 +21,6 @@ describe("resolveTelegramTargetChatType", () => {
it("handles tg/group prefixes and topic suffixes", () => { it("handles tg/group prefixes and topic suffixes", () => {
expect(resolveTelegramTargetChatType("tg:5232990709")).toBe("direct"); expect(resolveTelegramTargetChatType("tg:5232990709")).toBe("direct");
expect(resolveTelegramTargetChatType("group:-123456789")).toBe("group");
expect(resolveTelegramTargetChatType("telegram:group:-1001234567890")).toBe("group"); expect(resolveTelegramTargetChatType("telegram:group:-1001234567890")).toBe("group");
expect(resolveTelegramTargetChatType("telegram:group:-1001234567890:topic:456")).toBe("group"); expect(resolveTelegramTargetChatType("telegram:group:-1001234567890:topic:456")).toBe("group");
expect(resolveTelegramTargetChatType("-1001234567890:456")).toBe("group"); expect(resolveTelegramTargetChatType("-1001234567890:456")).toBe("group");

View File

@@ -11,6 +11,10 @@ describe("stripTelegramInternalPrefixes", () => {
expect(stripTelegramInternalPrefixes("telegram:group:-100123")).toBe("-100123"); expect(stripTelegramInternalPrefixes("telegram:group:-100123")).toBe("-100123");
}); });
it("does not strip group prefix without telegram prefix", () => {
expect(stripTelegramInternalPrefixes("group:-100123")).toBe("group:-100123");
});
it("is idempotent", () => { it("is idempotent", () => {
expect(stripTelegramInternalPrefixes("@mychannel")).toBe("@mychannel"); expect(stripTelegramInternalPrefixes("@mychannel")).toBe("@mychannel");
}); });

View File

@@ -5,8 +5,19 @@ export type TelegramTarget = {
export function stripTelegramInternalPrefixes(to: string): string { export function stripTelegramInternalPrefixes(to: string): string {
let trimmed = to.trim(); let trimmed = to.trim();
let strippedTelegramPrefix = false;
while (true) { while (true) {
const next = trimmed.replace(/^(telegram|tg|group):/i, "").trim(); const next = (() => {
if (/^(telegram|tg):/i.test(trimmed)) {
strippedTelegramPrefix = true;
return trimmed.replace(/^(telegram|tg):/i, "").trim();
}
// Legacy internal form: `telegram:group:<id>` (still emitted by session keys).
if (strippedTelegramPrefix && /^group:/i.test(trimmed)) {
return trimmed.replace(/^group:/i, "").trim();
}
return trimmed;
})();
if (next === trimmed) return trimmed; if (next === trimmed) return trimmed;
trimmed = next; trimmed = next;
} }

View File

@@ -57,7 +57,7 @@ export type GatewaySessionList = {
label?: string; label?: string;
displayName?: string; displayName?: string;
provider?: string; provider?: string;
room?: string; groupChannel?: string;
space?: string; space?: string;
subject?: string; subject?: string;
chatType?: string; chatType?: string;