refactor: prune legacy group prefixes

This commit is contained in:
Peter Steinberger
2026-01-17 08:46:19 +00:00
parent ab49fe0e92
commit 13b931c006
44 changed files with 160 additions and 179 deletions

View File

@@ -534,7 +534,7 @@ async function processMessageWithPipeline(params: {
Body: body, Body: body,
RawBody: rawBody, RawBody: rawBody,
CommandBody: rawBody, CommandBody: rawBody,
From: isGroup ? `group:${chatId}` : `zalo:${senderId}`, From: isGroup ? `zalo:group:${chatId}` : `zalo:${senderId}`,
To: `zalo:${chatId}`, To: `zalo:${chatId}`,
SessionKey: route.sessionKey, SessionKey: route.sessionKey,
AccountId: route.accountId, AccountId: route.accountId,

View File

@@ -195,9 +195,8 @@ async function processMessage(
}, },
}); });
const fromLabel = isGroup const rawBody = content.trim();
? `group:${chatId}` const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
: senderName || `user:${senderId}`;
const body = deps.formatAgentEnvelope({ const body = deps.formatAgentEnvelope({
channel: "Zalo Personal", channel: "Zalo Personal",
from: fromLabel, from: fromLabel,
@@ -209,7 +208,7 @@ async function processMessage(
Body: body, Body: body,
RawBody: rawBody, RawBody: rawBody,
CommandBody: rawBody, CommandBody: rawBody,
From: isGroup ? `group:${chatId}` : `zalouser:${senderId}`, From: isGroup ? `zalouser:group:${chatId}` : `zalouser:${senderId}`,
To: `zalouser:${chatId}`, To: `zalouser:${chatId}`,
SessionKey: route.sessionKey, SessionKey: route.sessionKey,
AccountId: route.accountId, AccountId: route.accountId,

View File

@@ -309,7 +309,6 @@ export function createSessionStatusTool(opts?: {
const isGroup = const isGroup =
resolved.entry.chatType === "group" || resolved.entry.chatType === "group" ||
resolved.entry.chatType === "channel" || resolved.entry.chatType === "channel" ||
resolved.key.startsWith("group:") ||
resolved.key.includes(":group:") || resolved.key.includes(":group:") ||
resolved.key.includes(":channel:"); resolved.key.includes(":channel:");
const groupActivation = isGroup const groupActivation = isGroup

View File

@@ -68,7 +68,7 @@ export function classifySessionKind(params: {
if (key.startsWith("hook:")) return "hook"; if (key.startsWith("hook:")) return "hook";
if (key.startsWith("node-") || key.startsWith("node:")) return "node"; if (key.startsWith("node-") || key.startsWith("node:")) return "node";
if (params.gatewayKind === "group") return "group"; if (params.gatewayKind === "group") return "group";
if (key.startsWith("group:") || key.includes(":group:") || key.includes(":channel:")) { if (key.includes(":group:") || key.includes(":channel:")) {
return "group"; return "group";
} }
return "other"; return "other";

View File

@@ -23,11 +23,13 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget
if (!channelRaw) return null; if (!channelRaw) return null;
const normalizedChannel = normalizeChannelId(channelRaw); const normalizedChannel = normalizeChannelId(channelRaw);
const channel = normalizedChannel ?? channelRaw.toLowerCase(); const channel = normalizedChannel ?? channelRaw.toLowerCase();
const kindTarget = normalizedChannel const kindTarget = (() => {
? kind === "channel" if (!normalizedChannel) return id;
? `channel:${id}` if (normalizedChannel === "discord" || normalizedChannel === "slack") {
: `group:${id}` return `channel:${id}`;
: id; }
return kind === "channel" ? `channel:${id}` : `group:${id}`;
})();
const normalized = normalizedChannel const normalized = normalizedChannel
? getChannelPlugin(normalizedChannel)?.messaging?.normalizeTarget?.(kindTarget) ? getChannelPlugin(normalizedChannel)?.messaging?.normalizeTarget?.(kindTarget)
: undefined; : undefined;

View File

@@ -109,7 +109,7 @@ describe("group intro prompts", () => {
await getReplyFromConfig( await getReplyFromConfig(
{ {
Body: "status update", Body: "status update",
From: "group:dev", From: "discord:group:dev",
To: "+1888", To: "+1888",
ChatType: "group", ChatType: "group",
GroupSubject: "Release Squad", GroupSubject: "Release Squad",
@@ -172,7 +172,7 @@ describe("group intro prompts", () => {
await getReplyFromConfig( await getReplyFromConfig(
{ {
Body: "ping", Body: "ping",
From: "group:tg", From: "telegram:group:tg",
To: "+1777", To: "+1777",
ChatType: "group", ChatType: "group",
GroupSubject: "Dev Chat", GroupSubject: "Dev Chat",

View File

@@ -211,7 +211,7 @@ describe("trigger handling", () => {
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
{ {
Body: "/elevated on", Body: "/elevated on",
From: "group:123@g.us", From: "whatsapp:group:123@g.us",
To: "whatsapp:+2000", To: "whatsapp:+2000",
Provider: "whatsapp", Provider: "whatsapp",
SenderE164: "+1000", SenderE164: "+1000",

View File

@@ -128,7 +128,7 @@ describe("trigger handling", () => {
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
{ {
Body: "/elevated off", Body: "/elevated off",
From: "group:123@g.us", From: "whatsapp:group:123@g.us",
To: "whatsapp:+2000", To: "whatsapp:+2000",
Provider: "whatsapp", Provider: "whatsapp",
SenderE164: "+1000", SenderE164: "+1000",
@@ -172,7 +172,7 @@ describe("trigger handling", () => {
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
{ {
Body: "/elevated on", Body: "/elevated on",
From: "group:123@g.us", From: "whatsapp:group:123@g.us",
To: "whatsapp:+2000", To: "whatsapp:+2000",
Provider: "whatsapp", Provider: "whatsapp",
SenderE164: "+1000", SenderE164: "+1000",

View File

@@ -135,7 +135,7 @@ describe("trigger handling", () => {
const ctx = { const ctx = {
Body: "hi", Body: "hi",
From: "group:whatsapp:demo", From: "whatsapp:group:demo",
To: "+2000", To: "+2000",
ChatType: "group" as const, ChatType: "group" as const,
Provider: "whatsapp" as const, Provider: "whatsapp" as const,

View File

@@ -75,7 +75,7 @@ describe("buildThreadingToolContext", () => {
const sessionCtx = { const sessionCtx = {
Provider: "imessage", Provider: "imessage",
ChatType: "group", ChatType: "group",
From: "group:7", From: "imessage:group:7",
To: "chat_id:7", To: "chat_id:7",
} as TemplateContext; } as TemplateContext;

View File

@@ -22,7 +22,7 @@ describe("resolveGroupRequireMention", () => {
}; };
const ctx: TemplateContext = { const ctx: TemplateContext = {
Provider: "discord", Provider: "discord",
From: "group:123", From: "discord:group:123",
GroupChannel: "#general", GroupChannel: "#general",
GroupSpace: "145", GroupSpace: "145",
}; };

View File

@@ -6,6 +6,26 @@ import { isInternalMessageChannel } from "../../utils/message-channel.js";
import { normalizeGroupActivation } from "../group-activation.js"; import { normalizeGroupActivation } from "../group-activation.js";
import type { TemplateContext } from "../templating.js"; import type { TemplateContext } from "../templating.js";
function extractGroupId(raw: string | undefined | null): string | undefined {
const trimmed = (raw ?? "").trim();
if (!trimmed) return undefined;
const parts = trimmed.split(":").filter(Boolean);
if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) {
return parts.slice(2).join(":") || undefined;
}
if (
parts.length >= 2 &&
parts[0]?.toLowerCase() === "whatsapp" &&
trimmed.toLowerCase().includes("@g.us")
) {
return parts.slice(1).join(":") || undefined;
}
if (parts.length >= 2 && (parts[0] === "group" || parts[0] === "channel")) {
return parts.slice(1).join(":") || undefined;
}
return trimmed;
}
export function resolveGroupRequireMention(params: { export function resolveGroupRequireMention(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
ctx: TemplateContext; ctx: TemplateContext;
@@ -15,7 +35,7 @@ export function resolveGroupRequireMention(params: {
const rawChannel = groupResolution?.channel ?? ctx.Provider?.trim(); const rawChannel = groupResolution?.channel ?? ctx.Provider?.trim();
const channel = normalizeChannelId(rawChannel); const channel = normalizeChannelId(rawChannel);
if (!channel) return true; if (!channel) return true;
const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, ""); const groupId = groupResolution?.id ?? extractGroupId(ctx.From);
const groupChannel = ctx.GroupChannel?.trim() ?? ctx.GroupSubject?.trim(); const groupChannel = ctx.GroupChannel?.trim() ?? ctx.GroupSubject?.trim();
const groupSpace = ctx.GroupSpace?.trim(); const groupSpace = ctx.GroupSpace?.trim();
const requireMention = getChannelDock(channel)?.groups?.resolveRequireMention?.({ const requireMention = getChannelDock(channel)?.groups?.resolveRequireMention?.({
@@ -61,7 +81,7 @@ export function buildGroupIntro(params: {
activation === "always" activation === "always"
? "Activation: always-on (you receive every group message)." ? "Activation: always-on (you receive every group message)."
: "Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included)."; : "Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included).";
const groupId = params.sessionCtx.From?.replace(/^group:/, ""); const groupId = params.sessionEntry?.groupId ?? extractGroupId(params.sessionCtx.From);
const groupChannel = params.sessionCtx.GroupChannel?.trim() ?? subject; const groupChannel = params.sessionCtx.GroupChannel?.trim() ?? subject;
const groupSpace = params.sessionCtx.GroupSpace?.trim(); const groupSpace = params.sessionCtx.GroupSpace?.trim();
const providerIdsLine = providerId const providerIdsLine = providerId

View File

@@ -9,7 +9,7 @@ describe("finalizeInboundContext", () => {
Body: "a\\nb\r\nc", Body: "a\\nb\r\nc",
RawBody: "raw\\nline", RawBody: "raw\\nline",
ChatType: "channel", ChatType: "channel",
From: "group:123@g.us", From: "whatsapp:group:123@g.us",
GroupSubject: "Test", GroupSubject: "Test",
}; };

View File

@@ -227,6 +227,7 @@ export async function initSessionState(params: {
displayName: baseEntry?.displayName, displayName: baseEntry?.displayName,
chatType: baseEntry?.chatType, chatType: baseEntry?.chatType,
channel: baseEntry?.channel, channel: baseEntry?.channel,
groupId: baseEntry?.groupId,
subject: baseEntry?.subject, subject: baseEntry?.subject,
room: baseEntry?.room, room: baseEntry?.room,
space: baseEntry?.space, space: baseEntry?.space,
@@ -256,6 +257,7 @@ export async function initSessionState(params: {
const nextSubject = nextRoom ? undefined : subject; const nextSubject = nextRoom ? undefined : subject;
sessionEntry.chatType = groupResolution.chatType ?? "group"; sessionEntry.chatType = groupResolution.chatType ?? "group";
sessionEntry.channel = channel; sessionEntry.channel = channel;
sessionEntry.groupId = groupResolution.id;
if (nextSubject) sessionEntry.subject = nextSubject; if (nextSubject) sessionEntry.subject = nextSubject;
if (nextRoom) sessionEntry.room = nextRoom; if (nextRoom) sessionEntry.room = nextRoom;
if (space) sessionEntry.space = space; if (space) sessionEntry.space = space;

View File

@@ -293,8 +293,7 @@ export function buildStatusMessage(args: StatusArgs): string {
entry?.chatType === "group" || entry?.chatType === "group" ||
entry?.chatType === "channel" || entry?.chatType === "channel" ||
Boolean(args.sessionKey?.includes(":group:")) || Boolean(args.sessionKey?.includes(":group:")) ||
Boolean(args.sessionKey?.includes(":channel:")) || Boolean(args.sessionKey?.includes(":channel:"));
Boolean(args.sessionKey?.startsWith("group:"));
const groupActivationValue = isGroupSession const groupActivationValue = isGroupSession
? (args.groupActivation ?? entry?.groupActivation ?? "mention") ? (args.groupActivation ?? entry?.groupActivation ?? "mention")
: undefined; : undefined;

View File

@@ -13,10 +13,6 @@ export function normalizeSlackMessagingTarget(raw: string): string | undefined {
const id = trimmed.slice(8).trim(); const id = trimmed.slice(8).trim();
return id ? `channel:${id}`.toLowerCase() : undefined; return id ? `channel:${id}`.toLowerCase() : undefined;
} }
if (trimmed.startsWith("group:")) {
const id = trimmed.slice(6).trim();
return id ? `channel:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("slack:")) { if (trimmed.startsWith("slack:")) {
const id = trimmed.slice(6).trim(); const id = trimmed.slice(6).trim();
return id ? `user:${id}`.toLowerCase() : undefined; return id ? `user:${id}`.toLowerCase() : undefined;
@@ -36,7 +32,7 @@ export function looksLikeSlackTargetId(raw: string): boolean {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return false; if (!trimmed) return false;
if (/^<@([A-Z0-9]+)>$/i.test(trimmed)) return true; if (/^<@([A-Z0-9]+)>$/i.test(trimmed)) return true;
if (/^(user|channel|group):/i.test(trimmed)) return true; if (/^(user|channel):/i.test(trimmed)) return true;
if (/^slack:/i.test(trimmed)) return true; if (/^slack:/i.test(trimmed)) return true;
if (/^[@#]/.test(trimmed)) return true; if (/^[@#]/.test(trimmed)) return true;
return /^[CUWGD][A-Z0-9]{8,}$/i.test(trimmed); return /^[CUWGD][A-Z0-9]{8,}$/i.test(trimmed);
@@ -55,10 +51,6 @@ export function normalizeDiscordMessagingTarget(raw: string): string | undefined
const id = trimmed.slice(8).trim(); const id = trimmed.slice(8).trim();
return id ? `channel:${id}`.toLowerCase() : undefined; return id ? `channel:${id}`.toLowerCase() : undefined;
} }
if (trimmed.startsWith("group:")) {
const id = trimmed.slice(6).trim();
return id ? `channel:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("discord:")) { if (trimmed.startsWith("discord:")) {
const id = trimmed.slice(8).trim(); const id = trimmed.slice(8).trim();
return id ? `user:${id}`.toLowerCase() : undefined; return id ? `user:${id}`.toLowerCase() : undefined;
@@ -74,7 +66,7 @@ export function looksLikeDiscordTargetId(raw: string): boolean {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return false; if (!trimmed) return false;
if (/^<@!?\d+>$/.test(trimmed)) return true; if (/^<@!?\d+>$/.test(trimmed)) return true;
if (/^(user|channel|group|discord):/i.test(trimmed)) return true; if (/^(user|channel|discord):/i.test(trimmed)) return true;
if (/^\d{6,}$/.test(trimmed)) return true; if (/^\d{6,}$/.test(trimmed)) return true;
return false; return false;
} }
@@ -87,8 +79,6 @@ export function normalizeTelegramMessagingTarget(raw: string): string | undefine
normalized = normalized.slice("telegram:".length).trim(); normalized = normalized.slice("telegram:".length).trim();
} else if (normalized.startsWith("tg:")) { } else if (normalized.startsWith("tg:")) {
normalized = normalized.slice("tg:".length).trim(); normalized = normalized.slice("tg:".length).trim();
} else if (normalized.startsWith("group:")) {
normalized = normalized.slice("group:".length).trim();
} }
if (!normalized) return undefined; if (!normalized) return undefined;
const tmeMatch = const tmeMatch =
@@ -102,7 +92,7 @@ export function normalizeTelegramMessagingTarget(raw: string): string | undefine
export function looksLikeTelegramTargetId(raw: string): boolean { export function looksLikeTelegramTargetId(raw: string): boolean {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return false; if (!trimmed) return false;
if (/^(telegram|tg|group):/i.test(trimmed)) return true; if (/^(telegram|tg):/i.test(trimmed)) return true;
if (trimmed.startsWith("@")) return true; if (trimmed.startsWith("@")) return true;
return /^-?\d{6,}$/.test(trimmed); return /^-?\d{6,}$/.test(trimmed);
} }

View File

@@ -13,10 +13,7 @@ function getSessionRecipients(cfg: ClawdbotConfig) {
const storePath = resolveStorePath(cfg.session?.store); const storePath = resolveStorePath(cfg.session?.store);
const store = loadSessionStore(storePath); const store = loadSessionStore(storePath);
const isGroupKey = (key: string) => const isGroupKey = (key: string) =>
key.startsWith("group:") || key.includes(":group:") || key.includes(":channel:") || key.includes("@g.us");
key.includes(":group:") ||
key.includes(":channel:") ||
key.includes("@g.us");
const isCronKey = (key: string) => key.startsWith("cron:"); const isCronKey = (key: string) => key.startsWith("cron:");
const recipients = Object.entries(store) const recipients = Object.entries(store)

View File

@@ -69,7 +69,7 @@ describe("doctor legacy state migrations", () => {
) as Record<string, { sessionId: string }>; ) as Record<string, { sessionId: string }>;
expect(store["agent:main:main"]?.sessionId).toBe("b"); expect(store["agent:main:main"]?.sessionId).toBe("b");
expect(store["agent:main:slack:channel:C123"]?.sessionId).toBe("c"); expect(store["agent:main:slack:channel:C123"]?.sessionId).toBe("c");
expect(store["group:abc"]?.sessionId).toBe("d"); expect(store["agent:main:unknown:group:abc"]?.sessionId).toBe("d");
expect(store["agent:main:subagent:xyz"]?.sessionId).toBe("e"); expect(store["agent:main:subagent:xyz"]?.sessionId).toBe("e");
}); });

View File

@@ -113,7 +113,7 @@ function classifyKey(key: string, entry?: SessionEntry): SessionRow["kind"] {
if (entry?.chatType === "group" || entry?.chatType === "channel") { if (entry?.chatType === "group" || entry?.chatType === "channel") {
return "group"; return "group";
} }
if (key.startsWith("group:") || key.includes(":group:") || key.includes(":channel:")) { if (key.includes(":group:") || key.includes(":channel:")) {
return "group"; return "group";
} }
return "direct"; return "direct";

View File

@@ -22,7 +22,7 @@ const classifyKey = (key: string, entry?: SessionEntry): SessionStatus["kind"] =
if (entry?.chatType === "group" || entry?.chatType === "channel") { if (entry?.chatType === "group" || entry?.chatType === "channel") {
return "group"; return "group";
} }
if (key.startsWith("group:") || key.includes(":group:") || key.includes(":channel:")) { if (key.includes(":group:") || key.includes(":channel:")) {
return "group"; return "group";
} }
return "direct"; return "direct";

View File

@@ -30,7 +30,9 @@ describe("sessions", () => {
}); });
it("keeps group chats distinct", () => { it("keeps group chats distinct", () => {
expect(deriveSessionKey("per-sender", { From: "12345-678@g.us" })).toBe("group:12345-678@g.us"); expect(deriveSessionKey("per-sender", { From: "12345-678@g.us" })).toBe(
"whatsapp:group:12345-678@g.us",
);
}); });
it("prefixes group keys with provider when available", () => { it("prefixes group keys with provider when available", () => {
@@ -45,7 +47,7 @@ describe("sessions", () => {
it("keeps explicit provider when provided in group key", () => { it("keeps explicit provider when provided in group key", () => {
expect( expect(
resolveSessionKey("per-sender", { From: "group:discord:12345", ChatType: "group" }, "main"), resolveSessionKey("per-sender", { From: "discord:group:12345", ChatType: "group" }, "main"),
).toBe("agent:main:discord:group:12345"); ).toBe("agent:main:discord:group:12345");
}); });
@@ -87,7 +89,7 @@ describe("sessions", () => {
it("leaves groups untouched even with main key", () => { it("leaves groups untouched even with main key", () => {
expect(resolveSessionKey("per-sender", { From: "12345-678@g.us" }, "main")).toBe( expect(resolveSessionKey("per-sender", { From: "12345-678@g.us" }, "main")).toBe(
"agent:main:group:12345-678@g.us", "agent:main:whatsapp:group:12345-678@g.us",
); );
}); });

View File

@@ -35,7 +35,7 @@ export function buildGroupDisplayName(params: {
(room && space (room && space
? `${space}${room.startsWith("#") ? "" : "#"}${room}` ? `${space}${room.startsWith("#") ? "" : "#"}${room}`
: room || subject || space || "") || ""; : room || subject || space || "") || "";
const fallbackId = params.id?.trim() || params.key.replace(/^group:/, ""); 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) {
@@ -52,84 +52,49 @@ export function buildGroupDisplayName(params: {
export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | null { export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | null {
const from = typeof ctx.From === "string" ? ctx.From.trim() : ""; const from = typeof ctx.From === "string" ? ctx.From.trim() : "";
if (!from) return null;
const chatType = ctx.ChatType?.trim().toLowerCase(); const chatType = ctx.ChatType?.trim().toLowerCase();
const isGroup = const normalizedChatType =
chatType === "group" || chatType === "channel" ? "channel" : chatType === "group" ? "group" : undefined;
from.startsWith("group:") ||
from.includes("@g.us") || const isWhatsAppGroupId = from.toLowerCase().endsWith("@g.us");
const looksLikeGroup =
normalizedChatType === "group" ||
normalizedChatType === "channel" ||
from.includes(":group:") || from.includes(":group:") ||
from.includes(":channel:"); from.includes(":channel:") ||
if (!isGroup) return null; isWhatsAppGroupId;
if (!looksLikeGroup) return null;
const providerHint = ctx.Provider?.trim().toLowerCase(); const providerHint = ctx.Provider?.trim().toLowerCase();
const hasGroupPrefix = from.startsWith("group:");
const raw = (hasGroupPrefix ? from.slice("group:".length) : from).trim();
let provider: string | undefined; const parts = from.split(":").filter(Boolean);
let kind: "group" | "channel" | undefined; const head = parts[0]?.trim().toLowerCase() ?? "";
let id = ""; const headIsSurface = head ? getGroupSurfaces().has(head) : false;
const parseKind = (value: string) => { const provider = headIsSurface
if (value === "channel") return "channel"; ? head
return "group"; : (providerHint ?? (isWhatsAppGroupId ? "whatsapp" : undefined));
}; if (!provider) return null;
const parseParts = (parts: string[]) => { const second = parts[1]?.trim().toLowerCase();
if (parts.length >= 2 && getGroupSurfaces().has(parts[0])) { const secondIsKind = second === "group" || second === "channel";
provider = parts[0]; const kind = secondIsKind
if (parts.length >= 3) { ? (second as "group" | "channel")
const kindCandidate = parts[1]; : from.includes(":channel:") || normalizedChatType === "channel"
if (["group", "channel"].includes(kindCandidate)) { ? "channel"
kind = parseKind(kindCandidate); : "group";
id = parts.slice(2).join(":"); const id = headIsSurface
} else { ? secondIsKind
id = parts.slice(1).join(":"); ? parts.slice(2).join(":")
} : parts.slice(1).join(":")
} else { : from;
id = parts[1]; const finalId = id.trim();
} if (!finalId) return null;
return;
}
if (parts.length >= 2 && ["group", "channel"].includes(parts[0])) {
kind = parseKind(parts[0]);
id = parts.slice(1).join(":");
}
};
if (hasGroupPrefix) {
const legacyParts = raw.split(":").filter(Boolean);
if (legacyParts.length > 1) {
parseParts(legacyParts);
} else {
id = raw;
}
} else if (from.includes("@g.us") && !from.includes(":")) {
id = from;
} else {
parseParts(from.split(":").filter(Boolean));
if (!id) {
id = raw || from;
}
}
const resolvedProvider = provider ?? providerHint;
if (!resolvedProvider) {
const legacy = hasGroupPrefix ? `group:${raw}` : `group:${from}`;
return {
key: legacy,
id: raw || from,
chatType: "group",
};
}
const resolvedKind = kind === "channel" ? "channel" : "group";
const key = `${resolvedProvider}:${resolvedKind}:${id || raw || from}`;
return { return {
key, key: `${provider}:${kind}:${finalId}`,
channel: resolvedProvider, channel: provider,
id: id || raw || from, id: finalId,
chatType: resolvedKind === "channel" ? "channel" : "group", chatType: kind === "channel" ? "channel" : "group",
}; };
} }

View File

@@ -31,7 +31,7 @@ export function resolveSessionKey(scope: SessionScope, ctx: MsgContext, mainKey?
agentId: DEFAULT_AGENT_ID, agentId: DEFAULT_AGENT_ID,
mainKey: canonicalMainKey, mainKey: canonicalMainKey,
}); });
const isGroup = raw.startsWith("group:") || raw.includes(":group:") || raw.includes(":channel:"); const isGroup = raw.includes(":group:") || raw.includes(":channel:");
if (!isGroup) return canonical; if (!isGroup) return canonical;
return `agent:${DEFAULT_AGENT_ID}:${raw}`; return `agent:${DEFAULT_AGENT_ID}:${raw}`;
} }

View File

@@ -65,6 +65,7 @@ export type SessionEntry = {
label?: string; label?: string;
displayName?: string; displayName?: string;
channel?: string; channel?: string;
groupId?: string;
subject?: string; subject?: string;
room?: string; room?: string;
space?: string; space?: string;

View File

@@ -223,7 +223,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
const effectiveFrom = isDirectMessage const effectiveFrom = isDirectMessage
? `discord:${author.id}` ? `discord:${author.id}`
: (autoThreadContext?.From ?? `group:${message.channelId}`); : (autoThreadContext?.From ?? `discord:channel:${message.channelId}`);
const effectiveTo = autoThreadContext?.To ?? replyTarget; const effectiveTo = autoThreadContext?.To ?? replyTarget;
if (!effectiveTo) { if (!effectiveTo) {
runtime.error?.(danger("discord: missing reply target")); runtime.error?.(danger("discord: missing reply target"));

View File

@@ -601,7 +601,11 @@ async function dispatchDiscordCommandInteraction(params: {
RawBody: prompt, RawBody: prompt,
CommandBody: prompt, CommandBody: prompt,
CommandArgs: commandArgs, CommandArgs: commandArgs,
From: isDirectMessage ? `discord:${user.id}` : `group:${channelId}`, From: isDirectMessage
? `discord:${user.id}`
: isGroupDm
? `discord:group:${channelId}`
: `discord:channel:${channelId}`,
To: `slash:${user.id}`, To: `slash:${user.id}`,
SessionKey: `agent:${route.agentId}:${sessionPrefix}:${user.id}`, SessionKey: `agent:${route.agentId}:${sessionPrefix}:${user.id}`,
CommandTargetSessionKey: route.sessionKey, CommandTargetSessionKey: route.sessionKey,

View File

@@ -28,7 +28,7 @@ describe("resolveDiscordAutoThreadContext", () => {
}); });
expect(context).not.toBeNull(); expect(context).not.toBeNull();
expect(context?.To).toBe("channel:thread"); expect(context?.To).toBe("channel:thread");
expect(context?.From).toBe("group:thread"); expect(context?.From).toBe("discord:channel:thread");
expect(context?.OriginatingTo).toBe("channel:thread"); expect(context?.OriginatingTo).toBe("channel:thread");
expect(context?.SessionKey).toBe( expect(context?.SessionKey).toBe(
buildAgentSessionKey({ buildAgentSessionKey({

View File

@@ -194,7 +194,7 @@ export function resolveDiscordAutoThreadContext(params: {
return { return {
createdThreadId, createdThreadId,
From: `group:${createdThreadId}`, From: `${params.channel}:channel:${createdThreadId}`,
To: `channel:${createdThreadId}`, To: `channel:${createdThreadId}`,
OriginatingTo: `channel:${createdThreadId}`, OriginatingTo: `channel:${createdThreadId}`,
SessionKey: threadSessionKey, SessionKey: threadSessionKey,

View File

@@ -17,20 +17,23 @@ describe("gateway session utils", () => {
expect(res.items).toEqual(["b", "c"]); expect(res.items).toEqual(["b", "c"]);
}); });
test("parseGroupKey handles group prefixes", () => { test("parseGroupKey handles group keys", () => {
expect(parseGroupKey("group:abc")).toEqual({ id: "abc" });
expect(parseGroupKey("discord:group:dev")).toEqual({ expect(parseGroupKey("discord:group:dev")).toEqual({
channel: "discord", channel: "discord",
kind: "group", kind: "group",
id: "dev", id: "dev",
}); });
expect(parseGroupKey("agent:ops:discord:group:dev")).toEqual({
channel: "discord",
kind: "group",
id: "dev",
});
expect(parseGroupKey("foo:bar")).toBeNull(); expect(parseGroupKey("foo:bar")).toBeNull();
}); });
test("classifySessionKey respects chat type + prefixes", () => { test("classifySessionKey respects chat type + prefixes", () => {
expect(classifySessionKey("global")).toBe("global"); expect(classifySessionKey("global")).toBe("global");
expect(classifySessionKey("unknown")).toBe("unknown"); expect(classifySessionKey("unknown")).toBe("unknown");
expect(classifySessionKey("group:abc")).toBe("group");
expect(classifySessionKey("discord:group:dev")).toBe("group"); expect(classifySessionKey("discord:group:dev")).toBe("group");
expect(classifySessionKey("main")).toBe("direct"); expect(classifySessionKey("main")).toBe("direct");
const entry = { chatType: "group" } as SessionEntry; const entry = { chatType: "group" } as SessionEntry;
@@ -52,7 +55,9 @@ describe("gateway session utils", () => {
session: { mainKey: "main" }, session: { mainKey: "main" },
agents: { list: [{ id: "ops", default: true }] }, agents: { list: [{ id: "ops", default: true }] },
} as ClawdbotConfig; } as ClawdbotConfig;
expect(resolveSessionStoreKey({ cfg, sessionKey: "group:123" })).toBe("agent:ops:group:123"); expect(resolveSessionStoreKey({ cfg, sessionKey: "discord:group:123" })).toBe(
"agent:ops:discord:group:123",
);
expect(resolveSessionStoreKey({ cfg, sessionKey: "agent:alpha:main" })).toBe( expect(resolveSessionStoreKey({ cfg, sessionKey: "agent:alpha:main" })).toBe(
"agent:alpha:main", "agent:alpha:main",
); );

View File

@@ -60,7 +60,7 @@ export function classifySessionKey(key: string, entry?: SessionEntry): GatewaySe
if (entry?.chatType === "group" || entry?.chatType === "channel") { if (entry?.chatType === "group" || entry?.chatType === "channel") {
return "group"; return "group";
} }
if (key.startsWith("group:") || key.includes(":group:") || key.includes(":channel:")) { if (key.includes(":group:") || key.includes(":channel:")) {
return "group"; return "group";
} }
return "direct"; return "direct";
@@ -71,10 +71,6 @@ export function parseGroupKey(
): { channel?: string; kind?: "group" | "channel"; id?: string } | null { ): { channel?: string; kind?: "group" | "channel"; id?: string } | null {
const agentParsed = parseAgentSessionKey(key); const agentParsed = parseAgentSessionKey(key);
const rawKey = agentParsed?.rest ?? key; const rawKey = agentParsed?.rest ?? key;
if (rawKey.startsWith("group:")) {
const raw = rawKey.slice("group:".length);
return raw ? { id: raw } : null;
}
const parts = rawKey.split(":").filter(Boolean); const parts = rawKey.split(":").filter(Boolean);
if (parts.length >= 3) { if (parts.length >= 3) {
const [channel, kind, ...rest] = parts; const [channel, kind, ...rest] = parts;

View File

@@ -422,7 +422,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
Body: combinedBody, Body: combinedBody,
RawBody: bodyText, RawBody: bodyText,
CommandBody: bodyText, CommandBody: bodyText,
From: isGroup ? `group:${chatId}` : `imessage:${sender}`, From: isGroup ? `imessage:group:${chatId ?? "unknown"}` : `imessage:${sender}`,
To: imessageTo, To: imessageTo,
SessionKey: route.sessionKey, SessionKey: route.sessionKey,
AccountId: route.accountId, AccountId: route.accountId,

View File

@@ -13,8 +13,8 @@ describe("imessage targets", () => {
expect(target).toEqual({ kind: "chat_id", chatId: 123 }); expect(target).toEqual({ kind: "chat_id", chatId: 123 });
}); });
it("parses group chat targets", () => { it("parses chat targets", () => {
const target = parseIMessageTarget("group:456"); const target = parseIMessageTarget("chat:456");
expect(target).toEqual({ kind: "chat_id", chatId: 456 }); expect(target).toEqual({ kind: "chat_id", chatId: 456 });
}); });

View File

@@ -53,8 +53,7 @@ export function parseIMessageTarget(raw: string): IMessageTarget {
const isChatTarget = const isChatTarget =
CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) || CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) || CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)) || CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p));
remainderLower.startsWith("group:");
if (isChatTarget) { if (isChatTarget) {
return parseIMessageTarget(remainder); return parseIMessageTarget(remainder);
} }
@@ -89,16 +88,6 @@ export function parseIMessageTarget(raw: string): IMessageTarget {
} }
} }
if (lower.startsWith("group:")) {
const value = stripPrefix(trimmed, "group:");
const chatId = Number.parseInt(value, 10);
if (Number.isFinite(chatId)) {
return { kind: "chat_id", chatId };
}
if (!value) throw new Error("group target is required");
return { kind: "chat_guid", chatGuid: value };
}
return { kind: "handle", to: trimmed, service: "auto" }; return { kind: "handle", to: trimmed, service: "auto" };
} }
@@ -137,13 +126,6 @@ export function parseIMessageAllowTarget(raw: string): IMessageAllowTarget {
} }
} }
if (lower.startsWith("group:")) {
const value = stripPrefix(trimmed, "group:");
const chatId = Number.parseInt(value, 10);
if (Number.isFinite(chatId)) return { kind: "chat_id", chatId };
if (value) return { kind: "chat_guid", chatGuid: value };
}
return { kind: "handle", handle: normalizeIMessageHandle(trimmed) }; return { kind: "handle", handle: normalizeIMessageHandle(trimmed) };
} }

View File

@@ -220,7 +220,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
const entry = { const entry = {
...baseEntry, ...baseEntry,
lastChannel: "whatsapp" as const, lastChannel: "whatsapp" as const,
lastTo: "whatsapp:group:120363401234567890@G.US", lastTo: "whatsapp:120363401234567890@G.US",
}; };
expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({ expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({
channel: "whatsapp", channel: "whatsapp",

View File

@@ -106,7 +106,7 @@ describe("runMessageAction context isolation", () => {
action: "send", action: "send",
params: { params: {
channel: "whatsapp", channel: "whatsapp",
target: "group:123@g.us", target: "123@g.us",
message: "hi", message: "hi",
}, },
toolContext: { currentChannelId: "123@g.us" }, toolContext: { currentChannelId: "123@g.us" },

View File

@@ -32,7 +32,7 @@ describe("resolveOutboundTarget", () => {
name: "normalizes prefixed/uppercase whatsapp group targets", name: "normalizes prefixed/uppercase whatsapp group targets",
input: { input: {
channel: "whatsapp" as const, channel: "whatsapp" as const,
to: " WhatsApp:Group:120363401234567890@G.US ", to: " WhatsApp:120363401234567890@G.US ",
}, },
expected: { ok: true as const, to: "120363401234567890@g.us" }, expected: { ok: true as const, to: "120363401234567890@g.us" },
}, },

View File

@@ -61,7 +61,15 @@ function isSurfaceGroupKey(key: string): boolean {
} }
function isLegacyGroupKey(key: string): boolean { function isLegacyGroupKey(key: string): boolean {
return key.startsWith("group:") || key.includes("@g.us"); const trimmed = key.trim();
if (!trimmed) return false;
if (trimmed.startsWith("group:")) return true;
const lower = trimmed.toLowerCase();
if (!lower.includes("@g.us")) return false;
// Legacy WhatsApp group keys: bare JID or "whatsapp:<jid>" without explicit ":group:" kind.
if (!trimmed.includes(":")) return true;
if (lower.startsWith("whatsapp:") && !trimmed.includes(":group:")) return true;
return false;
} }
function normalizeSessionKeyForAgent(key: string, agentId: string): string { function normalizeSessionKeyForAgent(key: string, agentId: string): string {
@@ -72,6 +80,22 @@ function normalizeSessionKeyForAgent(key: string, agentId: string): string {
const rest = raw.slice("subagent:".length); const rest = raw.slice("subagent:".length);
return `agent:${normalizeAgentId(agentId)}:subagent:${rest}`; return `agent:${normalizeAgentId(agentId)}:subagent:${rest}`;
} }
if (raw.startsWith("group:")) {
const id = raw.slice("group:".length).trim();
if (!id) return raw;
const channel = id.toLowerCase().includes("@g.us") ? "whatsapp" : "unknown";
return `agent:${normalizeAgentId(agentId)}:${channel}:group:${id}`;
}
if (!raw.includes(":") && raw.toLowerCase().includes("@g.us")) {
return `agent:${normalizeAgentId(agentId)}:whatsapp:group:${raw}`;
}
if (raw.toLowerCase().startsWith("whatsapp:") && raw.toLowerCase().includes("@g.us")) {
const remainder = raw.slice("whatsapp:".length).trim();
const cleaned = remainder.replace(/^group:/i, "").trim();
if (cleaned && !isSurfaceGroupKey(raw)) {
return `agent:${normalizeAgentId(agentId)}:whatsapp:group:${cleaned}`;
}
}
if (isSurfaceGroupKey(raw)) { if (isSurfaceGroupKey(raw)) {
return `agent:${normalizeAgentId(agentId)}:${raw}`; return `agent:${normalizeAgentId(agentId)}:${raw}`;
} }

View File

@@ -27,7 +27,7 @@ function deriveChannelFromKey(key?: string) {
function deriveChatTypeFromKey(key?: string): SessionChatType | undefined { function deriveChatTypeFromKey(key?: string): SessionChatType | undefined {
if (!key) return undefined; if (!key) return undefined;
if (key.startsWith("group:") || key.includes(":group:")) return "group"; if (key.includes(":group:")) return "group";
if (key.includes(":channel:")) return "channel"; if (key.includes(":channel:")) return "channel";
return undefined; return undefined;
} }

View File

@@ -269,7 +269,7 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0]; const payload = replySpy.mock.calls[0][0];
expect(payload.SessionKey).toContain("telegram:group:-1001234567890:topic:99"); expect(payload.SessionKey).toContain("telegram:group:-1001234567890:topic:99");
expect(payload.From).toBe("group:-1001234567890:topic:99"); expect(payload.From).toBe("telegram:group:-1001234567890:topic:99");
expect(payload.MessageThreadId).toBe(99); expect(payload.MessageThreadId).toBe(99);
expect(payload.IsForum).toBe(true); expect(payload.IsForum).toBe(true);
expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", { expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", {

View File

@@ -1815,7 +1815,7 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0]; const payload = replySpy.mock.calls[0][0];
expect(payload.SessionKey).toContain("telegram:group:-1001234567890:topic:99"); expect(payload.SessionKey).toContain("telegram:group:-1001234567890:topic:99");
expect(payload.From).toBe("group:-1001234567890:topic:99"); expect(payload.From).toBe("telegram:group:-1001234567890:topic:99");
expect(payload.MessageThreadId).toBe(99); expect(payload.MessageThreadId).toBe(99);
expect(payload.IsForum).toBe(true); expect(payload.IsForum).toBe(true);
expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", { expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", {

View File

@@ -59,7 +59,7 @@ export function buildTelegramGroupPeerId(chatId: number | string, messageThreadI
} }
export function buildTelegramGroupFrom(chatId: number | string, messageThreadId?: number) { export function buildTelegramGroupFrom(chatId: number | string, messageThreadId?: number) {
return messageThreadId != null ? `group:${chatId}:topic:${messageThreadId}` : `group:${chatId}`; return `telegram:group:${buildTelegramGroupPeerId(chatId, messageThreadId)}`;
} }
export function buildSenderName(msg: TelegramMessage) { export function buildSenderName(msg: TelegramMessage) {

View File

@@ -41,7 +41,7 @@ describe("applyGroupGating", () => {
sendMedia: async () => {}, sendMedia: async () => {},
}, },
conversationId: "123@g.us", conversationId: "123@g.us",
groupHistoryKey: "group:123@g.us", groupHistoryKey: "whatsapp:default:group:123@g.us",
agentId: "main", agentId: "main",
sessionKey: "agent:main:whatsapp:group:123@g.us", sessionKey: "agent:main:whatsapp:group:123@g.us",
baseMentionConfig: { mentionRegexes: [] }, baseMentionConfig: { mentionRegexes: [] },

View File

@@ -9,15 +9,6 @@ describe("normalizeWhatsAppTarget", () => {
expect(normalizeWhatsAppTarget("whatsapp:120363401234567890@g.us")).toBe( expect(normalizeWhatsAppTarget("whatsapp:120363401234567890@g.us")).toBe(
"120363401234567890@g.us", "120363401234567890@g.us",
); );
expect(normalizeWhatsAppTarget("whatsapp:group:120363401234567890@g.us")).toBe(
"120363401234567890@g.us",
);
expect(normalizeWhatsAppTarget("group:123456789-987654321@g.us")).toBe(
"123456789-987654321@g.us",
);
expect(normalizeWhatsAppTarget(" WhatsApp:Group:123456789-987654321@G.US ")).toBe(
"123456789-987654321@g.us",
);
}); });
it("normalizes direct JIDs to E.164", () => { it("normalizes direct JIDs to E.164", () => {
@@ -43,12 +34,15 @@ describe("normalizeWhatsAppTarget", () => {
expect(normalizeWhatsAppTarget("whatsapp:")).toBeNull(); expect(normalizeWhatsAppTarget("whatsapp:")).toBeNull();
expect(normalizeWhatsAppTarget("@g.us")).toBeNull(); expect(normalizeWhatsAppTarget("@g.us")).toBeNull();
expect(normalizeWhatsAppTarget("whatsapp:group:@g.us")).toBeNull(); expect(normalizeWhatsAppTarget("whatsapp:group:@g.us")).toBeNull();
expect(normalizeWhatsAppTarget("whatsapp:group:120363401234567890@g.us")).toBeNull();
expect(normalizeWhatsAppTarget("group:123456789-987654321@g.us")).toBeNull();
expect(normalizeWhatsAppTarget(" WhatsApp:Group:123456789-987654321@G.US ")).toBeNull();
expect(normalizeWhatsAppTarget("abc@s.whatsapp.net")).toBeNull(); expect(normalizeWhatsAppTarget("abc@s.whatsapp.net")).toBeNull();
}); });
it("handles repeated prefixes", () => { it("handles repeated prefixes", () => {
expect(normalizeWhatsAppTarget("whatsapp:whatsapp:+1555")).toBe("+1555"); expect(normalizeWhatsAppTarget("whatsapp:whatsapp:+1555")).toBe("+1555");
expect(normalizeWhatsAppTarget("group:group:120@g.us")).toBe("120@g.us"); expect(normalizeWhatsAppTarget("group:group:120@g.us")).toBeNull();
}); });
}); });
@@ -70,7 +64,7 @@ describe("isWhatsAppGroupJid", () => {
expect(isWhatsAppGroupJid("120363401234567890@g.us")).toBe(true); expect(isWhatsAppGroupJid("120363401234567890@g.us")).toBe(true);
expect(isWhatsAppGroupJid("123456789-987654321@g.us")).toBe(true); expect(isWhatsAppGroupJid("123456789-987654321@g.us")).toBe(true);
expect(isWhatsAppGroupJid("whatsapp:120363401234567890@g.us")).toBe(true); expect(isWhatsAppGroupJid("whatsapp:120363401234567890@g.us")).toBe(true);
expect(isWhatsAppGroupJid("whatsapp:group:120363401234567890@g.us")).toBe(true); expect(isWhatsAppGroupJid("whatsapp:group:120363401234567890@g.us")).toBe(false);
expect(isWhatsAppGroupJid("x@g.us")).toBe(false); expect(isWhatsAppGroupJid("x@g.us")).toBe(false);
expect(isWhatsAppGroupJid("@g.us")).toBe(false); expect(isWhatsAppGroupJid("@g.us")).toBe(false);
expect(isWhatsAppGroupJid("120@g.usx")).toBe(false); expect(isWhatsAppGroupJid("120@g.usx")).toBe(false);

View File

@@ -7,10 +7,7 @@ function stripWhatsAppTargetPrefixes(value: string): string {
let candidate = value.trim(); let candidate = value.trim();
for (;;) { for (;;) {
const before = candidate; const before = candidate;
candidate = candidate candidate = candidate.replace(/^whatsapp:/i, "").trim();
.replace(/^whatsapp:/i, "")
.replace(/^group:/i, "")
.trim();
if (candidate === before) return candidate; if (candidate === before) return candidate;
} }
} }
@@ -59,6 +56,9 @@ export function normalizeWhatsAppTarget(value: string): string | null {
const normalized = normalizeE164(phone); const normalized = normalizeE164(phone);
return normalized.length > 1 ? normalized : null; return normalized.length > 1 ? normalized : null;
} }
// If the caller passed a JID-ish string that we don't understand, fail fast.
// Otherwise normalizeE164 would happily treat "group:120@g.us" as a phone number.
if (candidate.includes("@")) return null;
const normalized = normalizeE164(candidate); const normalized = normalizeE164(candidate);
return normalized.length > 1 ? normalized : null; return normalized.length > 1 ? normalized : null;
} }