diff --git a/src/auto-reply/group-activation.ts b/src/auto-reply/group-activation.ts new file mode 100644 index 000000000..9372da5fa --- /dev/null +++ b/src/auto-reply/group-activation.ts @@ -0,0 +1,23 @@ +export type GroupActivationMode = "mention" | "always"; + +export function normalizeGroupActivation( + raw?: string | null, +): GroupActivationMode | undefined { + const value = raw?.trim().toLowerCase(); + if (value === "mention") return "mention"; + if (value === "always") return "always"; + return undefined; +} + +export function parseActivationCommand(raw?: string): { + hasCommand: boolean; + mode?: GroupActivationMode; +} { + if (!raw) return { hasCommand: false }; + const trimmed = raw.trim(); + if (!trimmed) return { hasCommand: false }; + const match = trimmed.match(/^\/?activation\b(?:\s+([a-zA-Z]+))?/i); + if (!match) return { hasCommand: false }; + const mode = normalizeGroupActivation(match[1]); + return { hasCommand: true, mode }; +} diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index d754301d8..4c1b0cfb3 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -99,6 +99,49 @@ describe("trigger handling", () => { }); }); + it("updates group activation when the owner sends /activation", async () => { + await withTempHome(async (home) => { + const cfg = makeCfg(home); + const res = await getReplyFromConfig( + { + Body: "/activation always", + From: "123@g.us", + To: "+2000", + ChatType: "group", + SenderE164: "+2000", + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Group activation set to always"); + const store = JSON.parse( + await fs.readFile(cfg.inbound.session.store, "utf-8"), + ) as Record; + expect(store["group:123@g.us"]?.groupActivation).toBe("always"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + + it("ignores /activation from non-owners in groups", async () => { + await withTempHome(async (home) => { + const cfg = makeCfg(home); + const res = await getReplyFromConfig( + { + Body: "/activation mention", + From: "123@g.us", + To: "+2000", + ChatType: "group", + SenderE164: "+999", + }, + {}, + cfg, + ); + expect(res).toBeUndefined(); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("runs a greeting prompt for a bare /new", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ @@ -132,7 +175,44 @@ describe("trigger handling", () => { expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? ""; - expect(prompt).toContain("A new session was started via /new"); + expect(prompt).toContain("A new session was started via /new or /reset"); + }); + }); + + it("runs a greeting prompt for a bare /reset", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "hello" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await getReplyFromConfig( + { + Body: "/reset", + From: "+1003", + To: "+2000", + }, + {}, + { + inbound: { + allowFrom: ["*"], + workspace: join(home, "clawd"), + agent: { provider: "anthropic", model: "claude-opus-4-5" }, + session: { + store: join(tmpdir(), `clawdis-session-test-${Date.now()}.json`), + }, + }, + }, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBe("hello"); + expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + const prompt = + vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? ""; + expect(prompt).toContain("A new session was started via /new or /reset"); }); }); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index b450968a0..7b251a69e 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -18,7 +18,7 @@ import { import { type ClawdisConfig, loadConfig } from "../config/config.js"; import { DEFAULT_IDLE_MINUTES, - DEFAULT_RESET_TRIGGER, + DEFAULT_RESET_TRIGGERS, loadSessionStore, resolveSessionKey, resolveSessionTranscriptPath, @@ -26,14 +26,18 @@ import { type SessionEntry, saveSessionStore, } from "../config/sessions.js"; -import { resolveGroupChatActivation } from "../config/group-chat.js"; import { logVerbose } from "../globals.js"; import { buildProviderSummary } from "../infra/provider-summary.js"; import { triggerClawdisRestart } from "../infra/restart.js"; import { drainSystemEvents } from "../infra/system-events.js"; import { defaultRuntime } from "../runtime.js"; +import { normalizeE164 } from "../utils.js"; import { resolveHeartbeatSeconds } from "../web/reconnect.js"; import { getWebAuthAgeMs, webAuthExists } from "../web/session.js"; +import { + parseActivationCommand, + normalizeGroupActivation, +} from "./group-activation.js"; import { buildStatusMessage } from "./status.js"; import type { MsgContext, TemplateContext } from "./templating.js"; import { @@ -53,7 +57,7 @@ const ABORT_MEMORY = new Map(); const SYSTEM_MARK = "⚙️"; const BARE_SESSION_RESET_PROMPT = - "A new session was started via /new. Say hi briefly and ask what the user wants to do next."; + "A new session was started via /new or /reset. Say hi briefly and ask what the user wants to do next."; export function extractThinkDirective(body?: string): { cleaned: string; @@ -219,7 +223,7 @@ export async function getReplyFromConfig( const mainKey = sessionCfg?.mainKey ?? "main"; const resetTriggers = sessionCfg?.resetTriggers?.length ? sessionCfg.resetTriggers - : [DEFAULT_RESET_TRIGGER]; + : DEFAULT_RESET_TRIGGERS; const idleMinutes = Math.max( sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, 1, @@ -502,6 +506,20 @@ export async function getReplyFromConfig( : defaultAllowFrom; const abortKey = sessionKey ?? (from || undefined) ?? (to || undefined); const rawBodyNormalized = triggerBodyNormalized; + const commandBodyNormalized = isGroup + ? stripMentions(rawBodyNormalized, ctx, cfg) + : rawBodyNormalized; + const activationCommand = parseActivationCommand(commandBodyNormalized); + const senderE164 = normalizeE164(ctx.SenderE164 ?? ""); + const ownerCandidates = (allowFrom ?? []).filter( + (entry) => entry && entry !== "*", + ); + if (ownerCandidates.length === 0 && to) ownerCandidates.push(to); + const ownerList = ownerCandidates + .map((entry) => normalizeE164(entry)) + .filter((entry): entry is string => Boolean(entry)); + const isOwnerSender = + Boolean(senderE164) && ownerList.includes(senderE164 ?? ""); if (!sessionEntry && abortKey) { abortedLastRun = ABORT_MEMORY.get(abortKey) ?? false; @@ -521,11 +539,46 @@ export async function getReplyFromConfig( } } + if (activationCommand.hasCommand) { + if (!isGroup) { + cleanupTyping(); + return { text: "⚙️ Group activation only applies to group chats." }; + } + if (!isOwnerSender) { + logVerbose( + `Ignoring /activation from non-owner in group: ${senderE164 || ""}`, + ); + cleanupTyping(); + return undefined; + } + if (!activationCommand.mode) { + cleanupTyping(); + return { text: "⚙️ Usage: /activation mention|always" }; + } + if (sessionEntry && sessionStore && sessionKey) { + sessionEntry.groupActivation = activationCommand.mode; + sessionEntry.updatedAt = Date.now(); + sessionStore[sessionKey] = sessionEntry; + await saveSessionStore(storePath, sessionStore); + } + cleanupTyping(); + return { + text: `⚙️ Group activation set to ${activationCommand.mode}.`, + }; + } + if ( - rawBodyNormalized === "/restart" || - rawBodyNormalized === "restart" || - rawBodyNormalized.startsWith("/restart ") + commandBodyNormalized === "/restart" || + commandBodyNormalized === "restart" || + commandBodyNormalized.startsWith("/restart ") ) { + if (isGroup && !isOwnerSender) { + logVerbose( + `Ignoring /restart from non-owner in group: ${senderE164 || ""}`, + ); + cleanupTyping(); + return undefined; + } triggerClawdisRestart(); cleanupTyping(); return { @@ -534,10 +587,17 @@ export async function getReplyFromConfig( } if ( - rawBodyNormalized === "/status" || - rawBodyNormalized === "status" || - rawBodyNormalized.startsWith("/status ") + commandBodyNormalized === "/status" || + commandBodyNormalized === "status" || + commandBodyNormalized.startsWith("/status ") ) { + if (isGroup && !isOwnerSender) { + logVerbose( + `Ignoring /status from non-owner in group: ${senderE164 || ""}`, + ); + cleanupTyping(); + return undefined; + } const webLinked = await webAuthExists(); const webAuthAgeMs = getWebAuthAgeMs(); const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); @@ -585,7 +645,9 @@ export async function getReplyFromConfig( const groupIntro = isFirstTurnInSession && sessionCtx.ChatType === "group" ? (() => { - const activation = resolveGroupChatActivation(cfg); + const activation = + normalizeGroupActivation(sessionEntry?.groupActivation) ?? + "mention"; const subject = sessionCtx.GroupSubject?.trim(); const members = sessionCtx.GroupMembers?.trim(); const subjectLine = subject diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index cad20ed1b..9873b5b6c 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -53,6 +53,22 @@ describe("buildStatusMessage", () => { expect(text).toContain("Web: not linked"); }); + it("includes group activation for group sessions", () => { + const text = buildStatusMessage({ + agent: {}, + sessionEntry: { + sessionId: "g1", + updatedAt: 0, + groupActivation: "always", + }, + sessionKey: "group:123@g.us", + sessionScope: "per-sender", + webLinked: true, + }); + + expect(text).toContain("Group activation: always"); + }); + it("prefers cached prompt tokens from the session log", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdis-status-")); const previousHome = process.env.HOME; diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 0bbe9a257..39af72a48 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -181,6 +181,11 @@ export function buildStatusMessage(args: StatusArgs): string { .filter(Boolean) .join(" • "); + const groupActivationLine = + args.sessionKey?.startsWith("group:") + ? `Group activation: ${entry?.groupActivation ?? "mention"}` + : undefined; + const contextLine = `Context: ${formatTokens( totalTokens, contextTokens ?? null, @@ -209,6 +214,7 @@ export function buildStatusMessage(args: StatusArgs): string { workspaceLine, contextLine, sessionLine, + groupActivationLine, optionsLine, helpersLine, ].join("\n"); diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 4abda1f27..d44aadd16 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -21,6 +21,7 @@ type SessionRow = { abortedLastRun?: boolean; thinkingLevel?: string; verboseLevel?: string; + groupActivation?: string; inputTokens?: number; outputTokens?: number; totalTokens?: number; @@ -93,6 +94,7 @@ const formatFlagsCell = (row: SessionRow, rich: boolean) => { const flags = [ row.thinkingLevel ? `think:${row.thinkingLevel}` : null, row.verboseLevel ? `verbose:${row.verboseLevel}` : null, + row.groupActivation ? `activation:${row.groupActivation}` : null, row.systemSent ? "system" : null, row.abortedLastRun ? "aborted" : null, row.sessionId ? `id:${row.sessionId}` : null, @@ -133,6 +135,7 @@ function toRows(store: Record): SessionRow[] { abortedLastRun: entry?.abortedLastRun, thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, + groupActivation: entry?.groupActivation, inputTokens: entry?.inputTokens, outputTokens: entry?.outputTokens, totalTokens: entry?.totalTokens, diff --git a/src/config/config.ts b/src/config/config.ts index b8c705212..9f0bb55ea 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -73,10 +73,7 @@ export type TelegramConfig = { webhookPath?: string; }; -export type GroupChatActivationMode = "mention" | "always"; - export type GroupChatConfig = { - activation?: GroupChatActivationMode; requireMention?: boolean; mentionPatterns?: string[]; historyLimit?: number; @@ -292,9 +289,6 @@ const ClawdisSchema = z.object({ timestampPrefix: z.union([z.boolean(), z.string()]).optional(), groupChat: z .object({ - activation: z - .union([z.literal("mention"), z.literal("always")]) - .optional(), requireMention: z.boolean().optional(), mentionPatterns: z.array(z.string()).optional(), historyLimit: z.number().int().positive().optional(), diff --git a/src/config/group-chat.ts b/src/config/group-chat.ts deleted file mode 100644 index 5363436a1..000000000 --- a/src/config/group-chat.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ClawdisConfig, GroupChatActivationMode } from "./config.js"; - -export function resolveGroupChatActivation( - cfg?: ClawdisConfig, -): GroupChatActivationMode { - const groupChat = cfg?.inbound?.groupChat; - if (groupChat?.activation === "always") return "always"; - if (groupChat?.activation === "mention") return "mention"; - if (groupChat?.requireMention === false) return "always"; - return "mention"; -} diff --git a/src/config/sessions.ts b/src/config/sessions.ts index fedd02e0d..f5d64601b 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -17,6 +17,7 @@ export type SessionEntry = { abortedLastRun?: boolean; thinkingLevel?: string; verboseLevel?: string; + groupActivation?: "mention" | "always"; inputTokens?: number; outputTokens?: number; totalTokens?: number; @@ -43,6 +44,7 @@ export function resolveDefaultSessionStorePath(): string { return path.join(resolveSessionTranscriptsDir(), "sessions.json"); } export const DEFAULT_RESET_TRIGGER = "/new"; +export const DEFAULT_RESET_TRIGGERS = ["/new", "/reset"]; export const DEFAULT_IDLE_MINUTES = 60; export function resolveSessionTranscriptPath(sessionId: string): string { diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 1e8530207..a27da20e0 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -291,6 +291,9 @@ export const SessionsPatchParamsSchema = Type.Object( key: NonEmptyString, thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + groupActivation: Type.Optional( + Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]), + ), syncing: Type.Optional( Type.Union([Type.Boolean(), NonEmptyString, Type.Null()]), ), diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 99bdb4d62..4ff0e0a5a 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -14,6 +14,7 @@ import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js"; import { installSkill } from "../agents/skills-install.js"; import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import { DEFAULT_AGENT_WORKSPACE_DIR } from "../agents/workspace.js"; +import { normalizeGroupActivation } from "../auto-reply/group-activation.js"; import { normalizeThinkLevel, normalizeVerboseLevel, @@ -1996,6 +1997,25 @@ export async function startGatewayServer( } } + if ("groupActivation" in p) { + const raw = p.groupActivation; + if (raw === null) { + delete next.groupActivation; + } else if (raw !== undefined) { + const normalized = normalizeGroupActivation(String(raw)); + if (!normalized) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid groupActivation: ${String(raw)}`, + }, + }; + } + next.groupActivation = normalized; + } + } + if ("syncing" in p) { const raw = p.syncing; if (raw === null) { @@ -4280,6 +4300,27 @@ export async function startGatewayServer( } } + if ("groupActivation" in p) { + const raw = p.groupActivation; + if (raw === null) { + delete next.groupActivation; + } else if (raw !== undefined) { + const normalized = normalizeGroupActivation(String(raw)); + if (!normalized) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + 'invalid groupActivation (use "mention"|"always")', + ), + ); + break; + } + next.groupActivation = normalized; + } + } + if ("syncing" in p) { const raw = p.syncing; if (raw === null) { diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 40d9736e8..6bd23e194 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -1441,9 +1441,18 @@ describe("web auto-reply", () => { .mockResolvedValueOnce({ text: SILENT_REPLY_TOKEN }) .mockResolvedValueOnce({ text: "ok" }); + const { storePath, cleanup } = await makeSessionStore({ + "group:123@g.us": { + sessionId: "g-1", + updatedAt: Date.now(), + groupActivation: "always", + }, + }); + setLoadConfigMock(() => ({ inbound: { - groupChat: { activation: "always", mentionPatterns: ["@clawd"] }, + groupChat: { mentionPatterns: ["@clawd"] }, + session: { store: storePath }, }, })); @@ -1504,6 +1513,7 @@ describe("web auto-reply", () => { expect(payload.Body).toContain("Bob: second"); expect(reply).toHaveBeenCalledTimes(1); + await cleanup(); resetLoadConfigMock(); }); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index c809cc88b..3f6871be3 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -2,13 +2,16 @@ import { chunkText } from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; +import { + normalizeGroupActivation, + parseActivationCommand, +} from "../auto-reply/group-activation.js"; import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN, } from "../auto-reply/tokens.js"; import { waitForever } from "../cli/wait.js"; import { loadConfig } from "../config/config.js"; -import { resolveGroupChatActivation } from "../config/group-chat.js"; import { DEFAULT_IDLE_MINUTES, loadSessionStore, @@ -108,16 +111,12 @@ function elide(text?: string, limit = 400) { } type MentionConfig = { - requireMention: boolean; mentionRegexes: RegExp[]; allowFrom?: Array; }; function buildMentionConfig(cfg: ReturnType): MentionConfig { const gc = cfg.inbound?.groupChat; - const activation = resolveGroupChatActivation(cfg); - const requireMention = - activation === "always" ? false : gc?.requireMention !== false; // default true const mentionRegexes = gc?.mentionPatterns ?.map((p) => { @@ -128,7 +127,7 @@ function buildMentionConfig(cfg: ReturnType): MentionConfig { } }) .filter((r): r is RegExp => Boolean(r)) ?? []; - return { requireMention, mentionRegexes, allowFrom: cfg.inbound?.allowFrom }; + return { mentionRegexes, allowFrom: cfg.inbound?.allowFrom }; } function isBotMentioned( @@ -769,6 +768,7 @@ export async function monitorWebProvider( ); const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect); const mentionConfig = buildMentionConfig(cfg); + const sessionStorePath = resolveStorePath(cfg.inbound?.session?.store); const groupHistoryLimit = cfg.inbound?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT; const groupHistories = new Map< @@ -843,6 +843,61 @@ export async function monitorWebProvider( .join(", "); }; + const resolveGroupActivationFor = (conversationId: string) => { + const key = conversationId.startsWith("group:") + ? conversationId + : `group:${conversationId}`; + const store = loadSessionStore(sessionStorePath); + const entry = store[key]; + return normalizeGroupActivation(entry?.groupActivation) ?? "mention"; + }; + + const resolveOwnerList = (selfE164?: string | null) => { + const allowFrom = mentionConfig.allowFrom; + const raw = + Array.isArray(allowFrom) && allowFrom.length > 0 + ? allowFrom + : selfE164 + ? [selfE164] + : []; + return raw + .filter((entry): entry is string => Boolean(entry && entry !== "*")) + .map((entry) => normalizeE164(entry)) + .filter((entry): entry is string => Boolean(entry)); + }; + + const isOwnerSender = (msg: WebInboundMsg) => { + const sender = normalizeE164(msg.senderE164 ?? ""); + if (!sender) return false; + const owners = resolveOwnerList(msg.selfE164 ?? undefined); + return owners.includes(sender); + }; + + const isStatusCommand = (body: string) => { + const trimmed = body.trim().toLowerCase(); + if (!trimmed) return false; + return ( + trimmed === "/status" || + trimmed === "status" || + trimmed.startsWith("/status ") + ); + }; + + const stripMentionsForCommand = (text: string, selfE164?: string | null) => { + let result = text; + for (const re of mentionConfig.mentionRegexes) { + result = result.replace(re, " "); + } + if (selfE164) { + const digits = selfE164.replace(/\D/g, ""); + if (digits) { + const pattern = new RegExp(`\\+?${digits}`, "g"); + result = result.replace(pattern, " "); + } + } + return result.replace(/\s+/g, " ").trim(); + }; + // Avoid noisy MaxListenersExceeded warnings in test environments where // multiple gateway instances may be constructed. const currentMaxListeners = process.getMaxListeners?.() ?? 10; @@ -1189,16 +1244,39 @@ export async function monitorWebProvider( if (msg.chatType === "group") { noteGroupMember(conversationId, msg.senderE164, msg.senderName); - const history = - groupHistories.get(conversationId) ?? - ([] as Array<{ sender: string; body: string; timestamp?: number }>); - history.push({ - sender: msg.senderName ?? msg.senderE164 ?? "Unknown", - body: msg.body, - timestamp: msg.timestamp, - }); - while (history.length > groupHistoryLimit) history.shift(); - groupHistories.set(conversationId, history); + const commandBody = stripMentionsForCommand( + msg.body, + msg.selfE164, + ); + const activationCommand = parseActivationCommand(commandBody); + const isOwner = isOwnerSender(msg); + const statusCommand = isStatusCommand(commandBody); + const shouldBypassMention = + isOwner && (activationCommand.hasCommand || statusCommand); + + if (activationCommand.hasCommand && !isOwner) { + logVerbose( + `Ignoring /activation from non-owner in group ${conversationId}`, + ); + return; + } + + if (!shouldBypassMention) { + const history = + groupHistories.get(conversationId) ?? + ([] as Array<{ + sender: string; + body: string; + timestamp?: number; + }>); + history.push({ + sender: msg.senderName ?? msg.senderE164 ?? "Unknown", + body: msg.body, + timestamp: msg.timestamp, + }); + while (history.length > groupHistoryLimit) history.shift(); + groupHistories.set(conversationId, history); + } const mentionDebug = debugMention(msg, mentionConfig); replyLogger.debug( @@ -1210,7 +1288,9 @@ export async function monitorWebProvider( "group mention debug", ); const wasMentioned = mentionDebug.wasMentioned; - if (mentionConfig.requireMention && !wasMentioned) { + const activation = resolveGroupActivationFor(conversationId); + const requireMention = activation !== "always"; + if (!shouldBypassMention && requireMention && !wasMentioned) { logVerbose( `Group message stored for context (no mention detected) in ${conversationId}: ${msg.body}`, );