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

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

View File

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

View File

@@ -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<string, boolean>();
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 || "<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 (
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 || "<unknown>"}`,
);
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 || "<unknown>"}`,
);
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

View File

@@ -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;

View File

@@ -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");