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,
RawBody: rawBody,
CommandBody: rawBody,
From: isGroup ? `group:${chatId}` : `zalo:${senderId}`,
From: isGroup ? `zalo:group:${chatId}` : `zalo:${senderId}`,
To: `zalo:${chatId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,

View File

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

View File

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

View File

@@ -68,7 +68,7 @@ export function classifySessionKind(params: {
if (key.startsWith("hook:")) return "hook";
if (key.startsWith("node-") || key.startsWith("node:")) return "node";
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 "other";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,26 @@ import { isInternalMessageChannel } from "../../utils/message-channel.js";
import { normalizeGroupActivation } from "../group-activation.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: {
cfg: ClawdbotConfig;
ctx: TemplateContext;
@@ -15,7 +35,7 @@ export function resolveGroupRequireMention(params: {
const rawChannel = groupResolution?.channel ?? ctx.Provider?.trim();
const channel = normalizeChannelId(rawChannel);
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 groupSpace = ctx.GroupSpace?.trim();
const requireMention = getChannelDock(channel)?.groups?.resolveRequireMention?.({
@@ -61,7 +81,7 @@ export function buildGroupIntro(params: {
activation === "always"
? "Activation: always-on (you receive every group message)."
: "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 groupSpace = params.sessionCtx.GroupSpace?.trim();
const providerIdsLine = providerId

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,10 +13,7 @@ function getSessionRecipients(cfg: ClawdbotConfig) {
const storePath = resolveStorePath(cfg.session?.store);
const store = loadSessionStore(storePath);
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 recipients = Object.entries(store)

View File

@@ -69,7 +69,7 @@ describe("doctor legacy state migrations", () => {
) as Record<string, { sessionId: string }>;
expect(store["agent:main:main"]?.sessionId).toBe("b");
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");
});

View File

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

View File

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

View File

@@ -30,7 +30,9 @@ describe("sessions", () => {
});
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", () => {
@@ -45,7 +47,7 @@ describe("sessions", () => {
it("keeps explicit provider when provided in group key", () => {
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");
});
@@ -87,7 +89,7 @@ describe("sessions", () => {
it("leaves groups untouched even with main key", () => {
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
? `${space}${room.startsWith("#") ? "" : "#"}${room}`
: room || subject || space || "") || "";
const fallbackId = params.id?.trim() || params.key.replace(/^group:/, "");
const fallbackId = params.id?.trim() || params.key;
const rawLabel = detail || fallbackId;
let token = normalizeGroupLabel(rawLabel);
if (!token) {
@@ -52,84 +52,49 @@ export function buildGroupDisplayName(params: {
export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | null {
const from = typeof ctx.From === "string" ? ctx.From.trim() : "";
if (!from) return null;
const chatType = ctx.ChatType?.trim().toLowerCase();
const isGroup =
chatType === "group" ||
from.startsWith("group:") ||
from.includes("@g.us") ||
const normalizedChatType =
chatType === "channel" ? "channel" : chatType === "group" ? "group" : undefined;
const isWhatsAppGroupId = from.toLowerCase().endsWith("@g.us");
const looksLikeGroup =
normalizedChatType === "group" ||
normalizedChatType === "channel" ||
from.includes(":group:") ||
from.includes(":channel:");
if (!isGroup) return null;
from.includes(":channel:") ||
isWhatsAppGroupId;
if (!looksLikeGroup) return null;
const providerHint = ctx.Provider?.trim().toLowerCase();
const hasGroupPrefix = from.startsWith("group:");
const raw = (hasGroupPrefix ? from.slice("group:".length) : from).trim();
let provider: string | undefined;
let kind: "group" | "channel" | undefined;
let id = "";
const parts = from.split(":").filter(Boolean);
const head = parts[0]?.trim().toLowerCase() ?? "";
const headIsSurface = head ? getGroupSurfaces().has(head) : false;
const parseKind = (value: string) => {
if (value === "channel") return "channel";
return "group";
};
const provider = headIsSurface
? head
: (providerHint ?? (isWhatsAppGroupId ? "whatsapp" : undefined));
if (!provider) return null;
const parseParts = (parts: string[]) => {
if (parts.length >= 2 && getGroupSurfaces().has(parts[0])) {
provider = parts[0];
if (parts.length >= 3) {
const kindCandidate = parts[1];
if (["group", "channel"].includes(kindCandidate)) {
kind = parseKind(kindCandidate);
id = parts.slice(2).join(":");
} else {
id = parts.slice(1).join(":");
}
} else {
id = parts[1];
}
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}`;
const second = parts[1]?.trim().toLowerCase();
const secondIsKind = second === "group" || second === "channel";
const kind = secondIsKind
? (second as "group" | "channel")
: from.includes(":channel:") || normalizedChatType === "channel"
? "channel"
: "group";
const id = headIsSurface
? secondIsKind
? parts.slice(2).join(":")
: parts.slice(1).join(":")
: from;
const finalId = id.trim();
if (!finalId) return null;
return {
key,
channel: resolvedProvider,
id: id || raw || from,
chatType: resolvedKind === "channel" ? "channel" : "group",
key: `${provider}:${kind}:${finalId}`,
channel: provider,
id: finalId,
chatType: kind === "channel" ? "channel" : "group",
};
}

View File

@@ -31,7 +31,7 @@ export function resolveSessionKey(scope: SessionScope, ctx: MsgContext, mainKey?
agentId: DEFAULT_AGENT_ID,
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;
return `agent:${DEFAULT_AGENT_ID}:${raw}`;
}

View File

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

View File

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

View File

@@ -601,7 +601,11 @@ async function dispatchDiscordCommandInteraction(params: {
RawBody: prompt,
CommandBody: prompt,
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}`,
SessionKey: `agent:${route.agentId}:${sessionPrefix}:${user.id}`,
CommandTargetSessionKey: route.sessionKey,

View File

@@ -28,7 +28,7 @@ describe("resolveDiscordAutoThreadContext", () => {
});
expect(context).not.toBeNull();
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?.SessionKey).toBe(
buildAgentSessionKey({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,8 +53,7 @@ export function parseIMessageTarget(raw: string): IMessageTarget {
const isChatTarget =
CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
remainderLower.startsWith("group:");
CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p));
if (isChatTarget) {
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" };
}
@@ -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) };
}

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ describe("resolveOutboundTarget", () => {
name: "normalizes prefixed/uppercase whatsapp group targets",
input: {
channel: "whatsapp" as const,
to: " WhatsApp:Group:120363401234567890@G.US ",
to: " WhatsApp: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 {
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 {
@@ -72,6 +80,22 @@ function normalizeSessionKeyForAgent(key: string, agentId: string): string {
const rest = raw.slice("subagent:".length);
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)) {
return `agent:${normalizeAgentId(agentId)}:${raw}`;
}

View File

@@ -27,7 +27,7 @@ function deriveChannelFromKey(key?: string) {
function deriveChatTypeFromKey(key?: string): SessionChatType | 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";
return undefined;
}

View File

@@ -269,7 +269,7 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
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.IsForum).toBe(true);
expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", {

View File

@@ -1815,7 +1815,7 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
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.IsForum).toBe(true);
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) {
return messageThreadId != null ? `group:${chatId}:topic:${messageThreadId}` : `group:${chatId}`;
return `telegram:group:${buildTelegramGroupPeerId(chatId, messageThreadId)}`;
}
export function buildSenderName(msg: TelegramMessage) {

View File

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

View File

@@ -9,15 +9,6 @@ describe("normalizeWhatsAppTarget", () => {
expect(normalizeWhatsAppTarget("whatsapp:120363401234567890@g.us")).toBe(
"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", () => {
@@ -43,12 +34,15 @@ describe("normalizeWhatsAppTarget", () => {
expect(normalizeWhatsAppTarget("whatsapp:")).toBeNull();
expect(normalizeWhatsAppTarget("@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();
});
it("handles repeated prefixes", () => {
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("123456789-987654321@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("@g.us")).toBe(false);
expect(isWhatsAppGroupJid("120@g.usx")).toBe(false);

View File

@@ -7,10 +7,7 @@ function stripWhatsAppTargetPrefixes(value: string): string {
let candidate = value.trim();
for (;;) {
const before = candidate;
candidate = candidate
.replace(/^whatsapp:/i, "")
.replace(/^group:/i, "")
.trim();
candidate = candidate.replace(/^whatsapp:/i, "").trim();
if (candidate === before) return candidate;
}
}
@@ -59,6 +56,9 @@ export function normalizeWhatsAppTarget(value: string): string | null {
const normalized = normalizeE164(phone);
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);
return normalized.length > 1 ? normalized : null;
}