feat: add group activation command
This commit is contained in:
23
src/auto-reply/group-activation.ts
Normal file
23
src/auto-reply/group-activation.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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<string, { groupActivation?: string }>;
|
||||||
|
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 () => {
|
it("runs a greeting prompt for a bare /new", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
@@ -132,7 +175,44 @@ describe("trigger handling", () => {
|
|||||||
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
||||||
const prompt =
|
const prompt =
|
||||||
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
import { type ClawdisConfig, loadConfig } from "../config/config.js";
|
import { type ClawdisConfig, loadConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_IDLE_MINUTES,
|
DEFAULT_IDLE_MINUTES,
|
||||||
DEFAULT_RESET_TRIGGER,
|
DEFAULT_RESET_TRIGGERS,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
resolveSessionKey,
|
resolveSessionKey,
|
||||||
resolveSessionTranscriptPath,
|
resolveSessionTranscriptPath,
|
||||||
@@ -26,14 +26,18 @@ import {
|
|||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
import { resolveGroupChatActivation } from "../config/group-chat.js";
|
|
||||||
import { logVerbose } from "../globals.js";
|
import { logVerbose } from "../globals.js";
|
||||||
import { buildProviderSummary } from "../infra/provider-summary.js";
|
import { buildProviderSummary } from "../infra/provider-summary.js";
|
||||||
import { triggerClawdisRestart } from "../infra/restart.js";
|
import { triggerClawdisRestart } from "../infra/restart.js";
|
||||||
import { drainSystemEvents } from "../infra/system-events.js";
|
import { drainSystemEvents } from "../infra/system-events.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
|
import { normalizeE164 } from "../utils.js";
|
||||||
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
||||||
import { getWebAuthAgeMs, webAuthExists } from "../web/session.js";
|
import { getWebAuthAgeMs, webAuthExists } from "../web/session.js";
|
||||||
|
import {
|
||||||
|
parseActivationCommand,
|
||||||
|
normalizeGroupActivation,
|
||||||
|
} from "./group-activation.js";
|
||||||
import { buildStatusMessage } from "./status.js";
|
import { buildStatusMessage } from "./status.js";
|
||||||
import type { MsgContext, TemplateContext } from "./templating.js";
|
import type { MsgContext, TemplateContext } from "./templating.js";
|
||||||
import {
|
import {
|
||||||
@@ -53,7 +57,7 @@ const ABORT_MEMORY = new Map<string, boolean>();
|
|||||||
const SYSTEM_MARK = "⚙️";
|
const SYSTEM_MARK = "⚙️";
|
||||||
|
|
||||||
const BARE_SESSION_RESET_PROMPT =
|
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): {
|
export function extractThinkDirective(body?: string): {
|
||||||
cleaned: string;
|
cleaned: string;
|
||||||
@@ -219,7 +223,7 @@ export async function getReplyFromConfig(
|
|||||||
const mainKey = sessionCfg?.mainKey ?? "main";
|
const mainKey = sessionCfg?.mainKey ?? "main";
|
||||||
const resetTriggers = sessionCfg?.resetTriggers?.length
|
const resetTriggers = sessionCfg?.resetTriggers?.length
|
||||||
? sessionCfg.resetTriggers
|
? sessionCfg.resetTriggers
|
||||||
: [DEFAULT_RESET_TRIGGER];
|
: DEFAULT_RESET_TRIGGERS;
|
||||||
const idleMinutes = Math.max(
|
const idleMinutes = Math.max(
|
||||||
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
|
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
|
||||||
1,
|
1,
|
||||||
@@ -502,6 +506,20 @@ export async function getReplyFromConfig(
|
|||||||
: defaultAllowFrom;
|
: defaultAllowFrom;
|
||||||
const abortKey = sessionKey ?? (from || undefined) ?? (to || undefined);
|
const abortKey = sessionKey ?? (from || undefined) ?? (to || undefined);
|
||||||
const rawBodyNormalized = triggerBodyNormalized;
|
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) {
|
if (!sessionEntry && abortKey) {
|
||||||
abortedLastRun = ABORT_MEMORY.get(abortKey) ?? false;
|
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 || "<unknown>"}`,
|
||||||
|
);
|
||||||
|
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 (
|
if (
|
||||||
rawBodyNormalized === "/restart" ||
|
commandBodyNormalized === "/restart" ||
|
||||||
rawBodyNormalized === "restart" ||
|
commandBodyNormalized === "restart" ||
|
||||||
rawBodyNormalized.startsWith("/restart ")
|
commandBodyNormalized.startsWith("/restart ")
|
||||||
) {
|
) {
|
||||||
|
if (isGroup && !isOwnerSender) {
|
||||||
|
logVerbose(
|
||||||
|
`Ignoring /restart from non-owner in group: ${senderE164 || "<unknown>"}`,
|
||||||
|
);
|
||||||
|
cleanupTyping();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
triggerClawdisRestart();
|
triggerClawdisRestart();
|
||||||
cleanupTyping();
|
cleanupTyping();
|
||||||
return {
|
return {
|
||||||
@@ -534,10 +587,17 @@ export async function getReplyFromConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
rawBodyNormalized === "/status" ||
|
commandBodyNormalized === "/status" ||
|
||||||
rawBodyNormalized === "status" ||
|
commandBodyNormalized === "status" ||
|
||||||
rawBodyNormalized.startsWith("/status ")
|
commandBodyNormalized.startsWith("/status ")
|
||||||
) {
|
) {
|
||||||
|
if (isGroup && !isOwnerSender) {
|
||||||
|
logVerbose(
|
||||||
|
`Ignoring /status from non-owner in group: ${senderE164 || "<unknown>"}`,
|
||||||
|
);
|
||||||
|
cleanupTyping();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const webLinked = await webAuthExists();
|
const webLinked = await webAuthExists();
|
||||||
const webAuthAgeMs = getWebAuthAgeMs();
|
const webAuthAgeMs = getWebAuthAgeMs();
|
||||||
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
|
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
|
||||||
@@ -585,7 +645,9 @@ export async function getReplyFromConfig(
|
|||||||
const groupIntro =
|
const groupIntro =
|
||||||
isFirstTurnInSession && sessionCtx.ChatType === "group"
|
isFirstTurnInSession && sessionCtx.ChatType === "group"
|
||||||
? (() => {
|
? (() => {
|
||||||
const activation = resolveGroupChatActivation(cfg);
|
const activation =
|
||||||
|
normalizeGroupActivation(sessionEntry?.groupActivation) ??
|
||||||
|
"mention";
|
||||||
const subject = sessionCtx.GroupSubject?.trim();
|
const subject = sessionCtx.GroupSubject?.trim();
|
||||||
const members = sessionCtx.GroupMembers?.trim();
|
const members = sessionCtx.GroupMembers?.trim();
|
||||||
const subjectLine = subject
|
const subjectLine = subject
|
||||||
|
|||||||
@@ -53,6 +53,22 @@ describe("buildStatusMessage", () => {
|
|||||||
expect(text).toContain("Web: not linked");
|
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 () => {
|
it("prefers cached prompt tokens from the session log", async () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdis-status-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdis-status-"));
|
||||||
const previousHome = process.env.HOME;
|
const previousHome = process.env.HOME;
|
||||||
|
|||||||
@@ -181,6 +181,11 @@ export function buildStatusMessage(args: StatusArgs): string {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" • ");
|
.join(" • ");
|
||||||
|
|
||||||
|
const groupActivationLine =
|
||||||
|
args.sessionKey?.startsWith("group:")
|
||||||
|
? `Group activation: ${entry?.groupActivation ?? "mention"}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const contextLine = `Context: ${formatTokens(
|
const contextLine = `Context: ${formatTokens(
|
||||||
totalTokens,
|
totalTokens,
|
||||||
contextTokens ?? null,
|
contextTokens ?? null,
|
||||||
@@ -209,6 +214,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
|||||||
workspaceLine,
|
workspaceLine,
|
||||||
contextLine,
|
contextLine,
|
||||||
sessionLine,
|
sessionLine,
|
||||||
|
groupActivationLine,
|
||||||
optionsLine,
|
optionsLine,
|
||||||
helpersLine,
|
helpersLine,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type SessionRow = {
|
|||||||
abortedLastRun?: boolean;
|
abortedLastRun?: boolean;
|
||||||
thinkingLevel?: string;
|
thinkingLevel?: string;
|
||||||
verboseLevel?: string;
|
verboseLevel?: string;
|
||||||
|
groupActivation?: string;
|
||||||
inputTokens?: number;
|
inputTokens?: number;
|
||||||
outputTokens?: number;
|
outputTokens?: number;
|
||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
@@ -93,6 +94,7 @@ const formatFlagsCell = (row: SessionRow, rich: boolean) => {
|
|||||||
const flags = [
|
const flags = [
|
||||||
row.thinkingLevel ? `think:${row.thinkingLevel}` : null,
|
row.thinkingLevel ? `think:${row.thinkingLevel}` : null,
|
||||||
row.verboseLevel ? `verbose:${row.verboseLevel}` : null,
|
row.verboseLevel ? `verbose:${row.verboseLevel}` : null,
|
||||||
|
row.groupActivation ? `activation:${row.groupActivation}` : null,
|
||||||
row.systemSent ? "system" : null,
|
row.systemSent ? "system" : null,
|
||||||
row.abortedLastRun ? "aborted" : null,
|
row.abortedLastRun ? "aborted" : null,
|
||||||
row.sessionId ? `id:${row.sessionId}` : null,
|
row.sessionId ? `id:${row.sessionId}` : null,
|
||||||
@@ -133,6 +135,7 @@ function toRows(store: Record<string, SessionEntry>): SessionRow[] {
|
|||||||
abortedLastRun: entry?.abortedLastRun,
|
abortedLastRun: entry?.abortedLastRun,
|
||||||
thinkingLevel: entry?.thinkingLevel,
|
thinkingLevel: entry?.thinkingLevel,
|
||||||
verboseLevel: entry?.verboseLevel,
|
verboseLevel: entry?.verboseLevel,
|
||||||
|
groupActivation: entry?.groupActivation,
|
||||||
inputTokens: entry?.inputTokens,
|
inputTokens: entry?.inputTokens,
|
||||||
outputTokens: entry?.outputTokens,
|
outputTokens: entry?.outputTokens,
|
||||||
totalTokens: entry?.totalTokens,
|
totalTokens: entry?.totalTokens,
|
||||||
|
|||||||
@@ -73,10 +73,7 @@ export type TelegramConfig = {
|
|||||||
webhookPath?: string;
|
webhookPath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GroupChatActivationMode = "mention" | "always";
|
|
||||||
|
|
||||||
export type GroupChatConfig = {
|
export type GroupChatConfig = {
|
||||||
activation?: GroupChatActivationMode;
|
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
mentionPatterns?: string[];
|
mentionPatterns?: string[];
|
||||||
historyLimit?: number;
|
historyLimit?: number;
|
||||||
@@ -292,9 +289,6 @@ const ClawdisSchema = z.object({
|
|||||||
timestampPrefix: z.union([z.boolean(), z.string()]).optional(),
|
timestampPrefix: z.union([z.boolean(), z.string()]).optional(),
|
||||||
groupChat: z
|
groupChat: z
|
||||||
.object({
|
.object({
|
||||||
activation: z
|
|
||||||
.union([z.literal("mention"), z.literal("always")])
|
|
||||||
.optional(),
|
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
mentionPatterns: z.array(z.string()).optional(),
|
mentionPatterns: z.array(z.string()).optional(),
|
||||||
historyLimit: z.number().int().positive().optional(),
|
historyLimit: z.number().int().positive().optional(),
|
||||||
|
|||||||
@@ -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";
|
|
||||||
}
|
|
||||||
@@ -17,6 +17,7 @@ export type SessionEntry = {
|
|||||||
abortedLastRun?: boolean;
|
abortedLastRun?: boolean;
|
||||||
thinkingLevel?: string;
|
thinkingLevel?: string;
|
||||||
verboseLevel?: string;
|
verboseLevel?: string;
|
||||||
|
groupActivation?: "mention" | "always";
|
||||||
inputTokens?: number;
|
inputTokens?: number;
|
||||||
outputTokens?: number;
|
outputTokens?: number;
|
||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
@@ -43,6 +44,7 @@ export function resolveDefaultSessionStorePath(): string {
|
|||||||
return path.join(resolveSessionTranscriptsDir(), "sessions.json");
|
return path.join(resolveSessionTranscriptsDir(), "sessions.json");
|
||||||
}
|
}
|
||||||
export const DEFAULT_RESET_TRIGGER = "/new";
|
export const DEFAULT_RESET_TRIGGER = "/new";
|
||||||
|
export const DEFAULT_RESET_TRIGGERS = ["/new", "/reset"];
|
||||||
export const DEFAULT_IDLE_MINUTES = 60;
|
export const DEFAULT_IDLE_MINUTES = 60;
|
||||||
|
|
||||||
export function resolveSessionTranscriptPath(sessionId: string): string {
|
export function resolveSessionTranscriptPath(sessionId: string): string {
|
||||||
|
|||||||
@@ -291,6 +291,9 @@ export const SessionsPatchParamsSchema = Type.Object(
|
|||||||
key: NonEmptyString,
|
key: NonEmptyString,
|
||||||
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
verboseLevel: 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(
|
syncing: Type.Optional(
|
||||||
Type.Union([Type.Boolean(), NonEmptyString, Type.Null()]),
|
Type.Union([Type.Boolean(), NonEmptyString, Type.Null()]),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js";
|
|||||||
import { installSkill } from "../agents/skills-install.js";
|
import { installSkill } from "../agents/skills-install.js";
|
||||||
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||||
import { DEFAULT_AGENT_WORKSPACE_DIR } from "../agents/workspace.js";
|
import { DEFAULT_AGENT_WORKSPACE_DIR } from "../agents/workspace.js";
|
||||||
|
import { normalizeGroupActivation } from "../auto-reply/group-activation.js";
|
||||||
import {
|
import {
|
||||||
normalizeThinkLevel,
|
normalizeThinkLevel,
|
||||||
normalizeVerboseLevel,
|
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) {
|
if ("syncing" in p) {
|
||||||
const raw = p.syncing;
|
const raw = p.syncing;
|
||||||
if (raw === null) {
|
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) {
|
if ("syncing" in p) {
|
||||||
const raw = p.syncing;
|
const raw = p.syncing;
|
||||||
if (raw === null) {
|
if (raw === null) {
|
||||||
|
|||||||
@@ -1441,9 +1441,18 @@ describe("web auto-reply", () => {
|
|||||||
.mockResolvedValueOnce({ text: SILENT_REPLY_TOKEN })
|
.mockResolvedValueOnce({ text: SILENT_REPLY_TOKEN })
|
||||||
.mockResolvedValueOnce({ text: "ok" });
|
.mockResolvedValueOnce({ text: "ok" });
|
||||||
|
|
||||||
|
const { storePath, cleanup } = await makeSessionStore({
|
||||||
|
"group:123@g.us": {
|
||||||
|
sessionId: "g-1",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
groupActivation: "always",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
setLoadConfigMock(() => ({
|
setLoadConfigMock(() => ({
|
||||||
inbound: {
|
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(payload.Body).toContain("Bob: second");
|
||||||
expect(reply).toHaveBeenCalledTimes(1);
|
expect(reply).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await cleanup();
|
||||||
resetLoadConfigMock();
|
resetLoadConfigMock();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import { chunkText } from "../auto-reply/chunk.js";
|
|||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
|
import {
|
||||||
|
normalizeGroupActivation,
|
||||||
|
parseActivationCommand,
|
||||||
|
} from "../auto-reply/group-activation.js";
|
||||||
import {
|
import {
|
||||||
HEARTBEAT_TOKEN,
|
HEARTBEAT_TOKEN,
|
||||||
SILENT_REPLY_TOKEN,
|
SILENT_REPLY_TOKEN,
|
||||||
} from "../auto-reply/tokens.js";
|
} from "../auto-reply/tokens.js";
|
||||||
import { waitForever } from "../cli/wait.js";
|
import { waitForever } from "../cli/wait.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { resolveGroupChatActivation } from "../config/group-chat.js";
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_IDLE_MINUTES,
|
DEFAULT_IDLE_MINUTES,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
@@ -108,16 +111,12 @@ function elide(text?: string, limit = 400) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MentionConfig = {
|
type MentionConfig = {
|
||||||
requireMention: boolean;
|
|
||||||
mentionRegexes: RegExp[];
|
mentionRegexes: RegExp[];
|
||||||
allowFrom?: Array<string | number>;
|
allowFrom?: Array<string | number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): MentionConfig {
|
function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): MentionConfig {
|
||||||
const gc = cfg.inbound?.groupChat;
|
const gc = cfg.inbound?.groupChat;
|
||||||
const activation = resolveGroupChatActivation(cfg);
|
|
||||||
const requireMention =
|
|
||||||
activation === "always" ? false : gc?.requireMention !== false; // default true
|
|
||||||
const mentionRegexes =
|
const mentionRegexes =
|
||||||
gc?.mentionPatterns
|
gc?.mentionPatterns
|
||||||
?.map((p) => {
|
?.map((p) => {
|
||||||
@@ -128,7 +127,7 @@ function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): MentionConfig {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((r): r is RegExp => Boolean(r)) ?? [];
|
.filter((r): r is RegExp => Boolean(r)) ?? [];
|
||||||
return { requireMention, mentionRegexes, allowFrom: cfg.inbound?.allowFrom };
|
return { mentionRegexes, allowFrom: cfg.inbound?.allowFrom };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBotMentioned(
|
function isBotMentioned(
|
||||||
@@ -769,6 +768,7 @@ export async function monitorWebProvider(
|
|||||||
);
|
);
|
||||||
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
|
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
|
||||||
const mentionConfig = buildMentionConfig(cfg);
|
const mentionConfig = buildMentionConfig(cfg);
|
||||||
|
const sessionStorePath = resolveStorePath(cfg.inbound?.session?.store);
|
||||||
const groupHistoryLimit =
|
const groupHistoryLimit =
|
||||||
cfg.inbound?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT;
|
cfg.inbound?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT;
|
||||||
const groupHistories = new Map<
|
const groupHistories = new Map<
|
||||||
@@ -843,6 +843,61 @@ export async function monitorWebProvider(
|
|||||||
.join(", ");
|
.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
|
// Avoid noisy MaxListenersExceeded warnings in test environments where
|
||||||
// multiple gateway instances may be constructed.
|
// multiple gateway instances may be constructed.
|
||||||
const currentMaxListeners = process.getMaxListeners?.() ?? 10;
|
const currentMaxListeners = process.getMaxListeners?.() ?? 10;
|
||||||
@@ -1189,16 +1244,39 @@ export async function monitorWebProvider(
|
|||||||
|
|
||||||
if (msg.chatType === "group") {
|
if (msg.chatType === "group") {
|
||||||
noteGroupMember(conversationId, msg.senderE164, msg.senderName);
|
noteGroupMember(conversationId, msg.senderE164, msg.senderName);
|
||||||
const history =
|
const commandBody = stripMentionsForCommand(
|
||||||
groupHistories.get(conversationId) ??
|
msg.body,
|
||||||
([] as Array<{ sender: string; body: string; timestamp?: number }>);
|
msg.selfE164,
|
||||||
history.push({
|
);
|
||||||
sender: msg.senderName ?? msg.senderE164 ?? "Unknown",
|
const activationCommand = parseActivationCommand(commandBody);
|
||||||
body: msg.body,
|
const isOwner = isOwnerSender(msg);
|
||||||
timestamp: msg.timestamp,
|
const statusCommand = isStatusCommand(commandBody);
|
||||||
});
|
const shouldBypassMention =
|
||||||
while (history.length > groupHistoryLimit) history.shift();
|
isOwner && (activationCommand.hasCommand || statusCommand);
|
||||||
groupHistories.set(conversationId, history);
|
|
||||||
|
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);
|
const mentionDebug = debugMention(msg, mentionConfig);
|
||||||
replyLogger.debug(
|
replyLogger.debug(
|
||||||
@@ -1210,7 +1288,9 @@ export async function monitorWebProvider(
|
|||||||
"group mention debug",
|
"group mention debug",
|
||||||
);
|
);
|
||||||
const wasMentioned = mentionDebug.wasMentioned;
|
const wasMentioned = mentionDebug.wasMentioned;
|
||||||
if (mentionConfig.requireMention && !wasMentioned) {
|
const activation = resolveGroupActivationFor(conversationId);
|
||||||
|
const requireMention = activation !== "always";
|
||||||
|
if (!shouldBypassMention && requireMention && !wasMentioned) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`Group message stored for context (no mention detected) in ${conversationId}: ${msg.body}`,
|
`Group message stored for context (no mention detected) in ${conversationId}: ${msg.body}`,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user