From 13b931c006566bda121246a543da44de8502f19e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Jan 2026 08:46:19 +0000 Subject: [PATCH] refactor: prune legacy group prefixes --- extensions/zalo/src/monitor.ts | 2 +- extensions/zalouser/src/monitor.ts | 7 +- src/agents/tools/session-status-tool.ts | 1 - src/agents/tools/sessions-helpers.ts | 2 +- src/agents/tools/sessions-send-helpers.ts | 12 +- ...reply.triggers.group-intro-prompts.test.ts | 4 +- ...proved-sender-toggle-elevated-mode.test.ts | 2 +- ...levated-off-groups-without-mention.test.ts | 4 +- ...bound-media-into-sandbox-workspace.test.ts | 2 +- .../reply/agent-runner-utils.test.ts | 2 +- src/auto-reply/reply/groups.test.ts | 2 +- src/auto-reply/reply/groups.ts | 24 +++- src/auto-reply/reply/inbound-context.test.ts | 2 +- src/auto-reply/reply/session.ts | 2 + src/auto-reply/status.ts | 3 +- src/channels/plugins/normalize-target.ts | 16 +-- src/channels/plugins/whatsapp-heartbeat.ts | 5 +- src/commands/doctor-state-migrations.test.ts | 2 +- src/commands/sessions.ts | 2 +- src/commands/status.summary.ts | 2 +- src/config/sessions.test.ts | 8 +- src/config/sessions/group.ts | 107 ++++++------------ src/config/sessions/session-key.ts | 2 +- src/config/sessions/types.ts | 1 + .../monitor/message-handler.process.ts | 2 +- src/discord/monitor/native-command.ts | 6 +- src/discord/monitor/threading.test.ts | 2 +- src/discord/monitor/threading.ts | 2 +- src/gateway/session-utils.test.ts | 13 ++- src/gateway/session-utils.ts | 6 +- src/imessage/monitor/monitor-provider.ts | 2 +- src/imessage/targets.test.ts | 4 +- src/imessage/targets.ts | 20 +--- ...tbeat-runner.returns-default-unset.test.ts | 2 +- .../outbound/message-action-runner.test.ts | 2 +- src/infra/outbound/targets.test.ts | 2 +- src/infra/state-migrations.ts | 26 ++++- src/sessions/send-policy.ts | 2 +- ...lowfrom-entries-case-insensitively.test.ts | 2 +- src/telegram/bot.test.ts | 2 +- src/telegram/bot/helpers.ts | 2 +- .../auto-reply/monitor/group-gating.test.ts | 2 +- src/whatsapp/normalize.test.ts | 16 +-- src/whatsapp/normalize.ts | 8 +- 44 files changed, 160 insertions(+), 179 deletions(-) diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index b43fbc546..82df82cb1 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -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, diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index b3273505d..c64d9bb6d 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -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, diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index bb1f2616c..167126cfb 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -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 diff --git a/src/agents/tools/sessions-helpers.ts b/src/agents/tools/sessions-helpers.ts index c0abdd6a1..a7f94b63b 100644 --- a/src/agents/tools/sessions-helpers.ts +++ b/src/agents/tools/sessions-helpers.ts @@ -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"; diff --git a/src/agents/tools/sessions-send-helpers.ts b/src/agents/tools/sessions-send-helpers.ts index 0e2611b85..2ef2416bf 100644 --- a/src/agents/tools/sessions-send-helpers.ts +++ b/src/agents/tools/sessions-send-helpers.ts @@ -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; diff --git a/src/auto-reply/reply.triggers.group-intro-prompts.test.ts b/src/auto-reply/reply.triggers.group-intro-prompts.test.ts index c3b238529..96c05e80f 100644 --- a/src/auto-reply/reply.triggers.group-intro-prompts.test.ts +++ b/src/auto-reply/reply.triggers.group-intro-prompts.test.ts @@ -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", diff --git a/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts b/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts index 972c61262..193172535 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts @@ -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", diff --git a/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.test.ts b/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.test.ts index 7df801092..8ace7a4bc 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.test.ts @@ -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", diff --git a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts index d0d6a5fc1..f3861b0b6 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts @@ -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, diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index 08741fac9..d9a5b5446 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -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; diff --git a/src/auto-reply/reply/groups.test.ts b/src/auto-reply/reply/groups.test.ts index 5db0601e1..6ae069141 100644 --- a/src/auto-reply/reply/groups.test.ts +++ b/src/auto-reply/reply/groups.test.ts @@ -22,7 +22,7 @@ describe("resolveGroupRequireMention", () => { }; const ctx: TemplateContext = { Provider: "discord", - From: "group:123", + From: "discord:group:123", GroupChannel: "#general", GroupSpace: "145", }; diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index 0897f32dd..f722f8eca 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -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 diff --git a/src/auto-reply/reply/inbound-context.test.ts b/src/auto-reply/reply/inbound-context.test.ts index a8155122d..5b2aa8473 100644 --- a/src/auto-reply/reply/inbound-context.test.ts +++ b/src/auto-reply/reply/inbound-context.test.ts @@ -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", }; diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index dc209809d..12c08e4af 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -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; diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index a90e7031c..8a703357a 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -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; diff --git a/src/channels/plugins/normalize-target.ts b/src/channels/plugins/normalize-target.ts index 9d93cbd8a..1c5691ad6 100644 --- a/src/channels/plugins/normalize-target.ts +++ b/src/channels/plugins/normalize-target.ts @@ -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); } diff --git a/src/channels/plugins/whatsapp-heartbeat.ts b/src/channels/plugins/whatsapp-heartbeat.ts index 52fadbb47..d00fc6a74 100644 --- a/src/channels/plugins/whatsapp-heartbeat.ts +++ b/src/channels/plugins/whatsapp-heartbeat.ts @@ -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) diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index 780a5446b..f6df975be 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -69,7 +69,7 @@ describe("doctor legacy state migrations", () => { ) as Record; 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"); }); diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 11a1bc884..1f1d5497f 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -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"; diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index 8eb73b74b..1845e29ea 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -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"; diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index a89caeae7..c78a9653c 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -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", ); }); diff --git a/src/config/sessions/group.ts b/src/config/sessions/group.ts index 4b8ae0e03..0896cfc67 100644 --- a/src/config/sessions/group.ts +++ b/src/config/sessions/group.ts @@ -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", }; } diff --git a/src/config/sessions/session-key.ts b/src/config/sessions/session-key.ts index 5da98ef7c..bd129caa4 100644 --- a/src/config/sessions/session-key.ts +++ b/src/config/sessions/session-key.ts @@ -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}`; } diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 3a5670b5f..bda7f561e 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -65,6 +65,7 @@ export type SessionEntry = { label?: string; displayName?: string; channel?: string; + groupId?: string; subject?: string; room?: string; space?: string; diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 164bd41c3..5f74522a6 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -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")); diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index bce122345..c78a116f7 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -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, diff --git a/src/discord/monitor/threading.test.ts b/src/discord/monitor/threading.test.ts index 580557187..34377869f 100644 --- a/src/discord/monitor/threading.test.ts +++ b/src/discord/monitor/threading.test.ts @@ -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({ diff --git a/src/discord/monitor/threading.ts b/src/discord/monitor/threading.ts index ecb355d37..ebbbbb199 100644 --- a/src/discord/monitor/threading.ts +++ b/src/discord/monitor/threading.ts @@ -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, diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 53055bbb1..db24c17f4 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -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", ); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index c0837e32a..c579d4997 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -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; diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index d780e446a..229eed5da 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -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, diff --git a/src/imessage/targets.test.ts b/src/imessage/targets.test.ts index ee1ad5ab2..956dfa321 100644 --- a/src/imessage/targets.test.ts +++ b/src/imessage/targets.test.ts @@ -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 }); }); diff --git a/src/imessage/targets.ts b/src/imessage/targets.ts index 196dba76e..befb3f6d6 100644 --- a/src/imessage/targets.ts +++ b/src/imessage/targets.ts @@ -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) }; } diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index dde804234..82bf21aa4 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -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", diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index 7b34527a1..97c532b42 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -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" }, diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 0c1d89796..c9d4d3f6e 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -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" }, }, diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index 345497e4a..7270da3f7 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -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:" 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}`; } diff --git a/src/sessions/send-policy.ts b/src/sessions/send-policy.ts index 16b4240c0..0d843c0a9 100644 --- a/src/sessions/send-policy.ts +++ b/src/sessions/send-policy.ts @@ -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; } diff --git a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts index 331389a35..dfdcf43e3 100644 --- a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts +++ b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts @@ -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", { diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index ad4deb039..b80ec2fcd 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -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", { diff --git a/src/telegram/bot/helpers.ts b/src/telegram/bot/helpers.ts index 40c2aea63..ad4411abf 100644 --- a/src/telegram/bot/helpers.ts +++ b/src/telegram/bot/helpers.ts @@ -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) { diff --git a/src/web/auto-reply/monitor/group-gating.test.ts b/src/web/auto-reply/monitor/group-gating.test.ts index 21e0b289d..8f76e557a 100644 --- a/src/web/auto-reply/monitor/group-gating.test.ts +++ b/src/web/auto-reply/monitor/group-gating.test.ts @@ -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: [] }, diff --git a/src/whatsapp/normalize.test.ts b/src/whatsapp/normalize.test.ts index 8dd80743f..19dd02381 100644 --- a/src/whatsapp/normalize.test.ts +++ b/src/whatsapp/normalize.test.ts @@ -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); diff --git a/src/whatsapp/normalize.ts b/src/whatsapp/normalize.ts index b8bee8c95..e5ddb0952 100644 --- a/src/whatsapp/normalize.ts +++ b/src/whatsapp/normalize.ts @@ -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; }