feat: add group activation command

This commit is contained in:
Peter Steinberger
2025-12-22 20:36:29 +01:00
parent 5d2d701e1e
commit f10c8f2b4c
13 changed files with 356 additions and 47 deletions

View File

@@ -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();
});

View File

@@ -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<string | number>;
};
function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): 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<typeof loadConfig>): 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}`,
);