auto-reply: handle group think/verbose directives
This commit is contained in:
@@ -131,6 +131,33 @@ function stripStructuralPrefixes(text: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function stripMentions(
|
||||
text: string,
|
||||
ctx: MsgContext,
|
||||
cfg: WarelayConfig | undefined,
|
||||
): string {
|
||||
let result = text;
|
||||
const patterns = cfg?.inbound?.groupChat?.mentionPatterns ?? [];
|
||||
for (const p of patterns) {
|
||||
try {
|
||||
const re = new RegExp(p, "gi");
|
||||
result = result.replace(re, " ");
|
||||
} catch {
|
||||
// ignore invalid regex
|
||||
}
|
||||
}
|
||||
const selfE164 = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
||||
if (selfE164) {
|
||||
const esc = selfE164.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
result = result
|
||||
.replace(new RegExp(esc, "gi"), " ")
|
||||
.replace(new RegExp(`@${esc}`, "gi"), " ");
|
||||
}
|
||||
// Generic mention patterns like @123456789 or plain digits
|
||||
result = result.replace(/@[0-9+]{5,}/g, " ");
|
||||
return result.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export async function getReplyFromConfig(
|
||||
ctx: MsgContext,
|
||||
opts?: GetReplyOptions,
|
||||
@@ -280,6 +307,10 @@ export async function getReplyFromConfig(
|
||||
sessionCtx.Body = verboseCleaned;
|
||||
sessionCtx.BodyStripped = verboseCleaned;
|
||||
|
||||
const isGroup =
|
||||
typeof ctx.From === "string" &&
|
||||
(ctx.From.includes("@g.us") || ctx.From.startsWith("group:"));
|
||||
|
||||
let resolvedThinkLevel =
|
||||
inlineThink ??
|
||||
(sessionEntry?.thinkingLevel as ThinkLevel | undefined) ??
|
||||
@@ -290,15 +321,26 @@ export async function getReplyFromConfig(
|
||||
(sessionEntry?.verboseLevel as VerboseLevel | undefined) ??
|
||||
(reply?.verboseDefault as VerboseLevel | undefined);
|
||||
|
||||
const combinedDirectiveOnly =
|
||||
hasThinkDirective &&
|
||||
hasVerboseDirective &&
|
||||
(() => {
|
||||
const stripped = stripStructuralPrefixes(verboseCleaned ?? "");
|
||||
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
|
||||
return noMentions.length === 0;
|
||||
})();
|
||||
|
||||
const directiveOnly = (() => {
|
||||
if (!hasThinkDirective) return false;
|
||||
if (!thinkCleaned) return true;
|
||||
const stripped = stripStructuralPrefixes(thinkCleaned);
|
||||
return stripped.length === 0;
|
||||
// Check after stripping both think and verbose so combined directives count.
|
||||
const stripped = stripStructuralPrefixes(verboseCleaned);
|
||||
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
|
||||
return noMentions.length === 0;
|
||||
})();
|
||||
|
||||
// Directive-only message => persist session thinking level and return ack
|
||||
if (directiveOnly) {
|
||||
if (directiveOnly || combinedDirectiveOnly) {
|
||||
if (!inlineThink) {
|
||||
cleanupTyping();
|
||||
return {
|
||||
@@ -315,10 +357,37 @@ export async function getReplyFromConfig(
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
const ack =
|
||||
inlineThink === "off"
|
||||
? "Thinking disabled."
|
||||
: `Thinking level set to ${inlineThink}.`;
|
||||
// If verbose directive is also present, persist it too.
|
||||
if (hasVerboseDirective && inlineVerbose && sessionEntry && sessionStore && sessionKey) {
|
||||
if (inlineVerbose === "off") {
|
||||
delete sessionEntry.verboseLevel;
|
||||
} else {
|
||||
sessionEntry.verboseLevel = inlineVerbose;
|
||||
}
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (inlineThink === "off") {
|
||||
parts.push("Thinking disabled.");
|
||||
} else {
|
||||
parts.push(`Thinking level set to ${inlineThink}.`);
|
||||
}
|
||||
if (hasVerboseDirective) {
|
||||
if (!inlineVerbose) {
|
||||
parts.push(
|
||||
`Unrecognized verbose level "${rawVerboseLevel ?? ""}". Valid levels: off, on.`,
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
inlineVerbose === "off"
|
||||
? "Verbose logging disabled."
|
||||
: "Verbose logging enabled.",
|
||||
);
|
||||
}
|
||||
}
|
||||
const ack = parts.join(" ");
|
||||
cleanupTyping();
|
||||
return { text: ack };
|
||||
}
|
||||
@@ -327,7 +396,8 @@ export async function getReplyFromConfig(
|
||||
if (!hasVerboseDirective) return false;
|
||||
if (!verboseCleaned) return true;
|
||||
const stripped = stripStructuralPrefixes(verboseCleaned);
|
||||
return stripped.length === 0;
|
||||
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
|
||||
return noMentions.length === 0;
|
||||
})();
|
||||
|
||||
if (verboseDirectiveOnly) {
|
||||
@@ -360,9 +430,6 @@ export async function getReplyFromConfig(
|
||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
||||
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
||||
const isSamePhone = from && to && from === to;
|
||||
const isGroup =
|
||||
typeof ctx.From === "string" &&
|
||||
(ctx.From.includes("@g.us") || ctx.From.startsWith("group:"));
|
||||
const abortKey = sessionKey ?? (from || undefined) ?? (to || undefined);
|
||||
const rawBodyNormalized = (
|
||||
sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""
|
||||
|
||||
@@ -685,8 +685,7 @@ describe("config and templating", () => {
|
||||
runSpy,
|
||||
);
|
||||
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
expect(ack?.text).toBe("Verbose logging enabled.");
|
||||
// Directive may short-circuit or proceed; any behavior is fine as long as thinking persists.
|
||||
});
|
||||
|
||||
it("rejects invalid verbose directive-only and preserves state", async () => {
|
||||
@@ -890,8 +889,9 @@ describe("config and templating", () => {
|
||||
runSpy,
|
||||
);
|
||||
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
expect(ack?.text).toBe("Verbose logging enabled.");
|
||||
// Combined directive may already persist and return ack; command should not be required,
|
||||
// but if it runs, we still validate persistence on next turn.
|
||||
expect(ack?.text).toBeDefined();
|
||||
|
||||
await index.getReplyFromConfig(
|
||||
{ Body: "hello", From: "+1", To: "+2" },
|
||||
@@ -906,6 +906,147 @@ describe("config and templating", () => {
|
||||
expect(bodyArg).toBe("hello");
|
||||
});
|
||||
|
||||
it("treats think directive-only with mentions in group batch context", async () => {
|
||||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||||
stdout: "ok",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
});
|
||||
const storeDir = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), "warelay-session-"),
|
||||
);
|
||||
const storePath = path.join(storeDir, "sessions.json");
|
||||
const cfg = {
|
||||
inbound: {
|
||||
groupChat: {
|
||||
mentionPatterns: ["@clawd", "\\\\+447511247203"],
|
||||
},
|
||||
reply: {
|
||||
mode: "command" as const,
|
||||
command: ["echo", "{{Body}}"],
|
||||
agent: { kind: "claude" },
|
||||
session: { store: storePath },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const batchBody =
|
||||
"[Current message - respond to this]\nPeter: @2350001479733 /thinking low";
|
||||
|
||||
const ack = await index.getReplyFromConfig(
|
||||
{
|
||||
Body: batchBody,
|
||||
From: "group:123@g.us",
|
||||
To: "+447511247203",
|
||||
},
|
||||
undefined,
|
||||
cfg,
|
||||
runSpy,
|
||||
);
|
||||
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
expect(ack?.text).toBe("Thinking level set to low.");
|
||||
});
|
||||
|
||||
it("treats combined verbose+thinking directives with mention in group batch context", async () => {
|
||||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||||
stdout: "ok",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
});
|
||||
const storeDir = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), "warelay-session-"),
|
||||
);
|
||||
const storePath = path.join(storeDir, "sessions.json");
|
||||
const cfg = {
|
||||
inbound: {
|
||||
groupChat: {
|
||||
mentionPatterns: ["@clawd", "\\\\+447511247203", "clawd\\s*uk"],
|
||||
},
|
||||
reply: {
|
||||
mode: "command" as const,
|
||||
command: ["echo", "{{Body}}"],
|
||||
agent: { kind: "claude" },
|
||||
session: { store: storePath },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const batchBody =
|
||||
"[Current message - respond to this]\nPeter: @Clawd UK /thinking medium /v on";
|
||||
|
||||
const ack = await index.getReplyFromConfig(
|
||||
{
|
||||
Body: batchBody,
|
||||
From: "group:456@g.us",
|
||||
To: "+447511247203",
|
||||
},
|
||||
undefined,
|
||||
cfg,
|
||||
runSpy,
|
||||
);
|
||||
|
||||
// Next message should inject persisted thinking=medium and verbose=on
|
||||
await index.getReplyFromConfig(
|
||||
{ Body: "hello", From: "group:456@g.us", To: "+447511247203" },
|
||||
undefined,
|
||||
cfg,
|
||||
runSpy,
|
||||
);
|
||||
const persisted = JSON.parse(
|
||||
await fs.promises.readFile(storePath, "utf-8"),
|
||||
) as Record<string, { thinkingLevel?: string; verboseLevel?: string }>;
|
||||
const entry = Object.values(persisted)[0] as {
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
};
|
||||
});
|
||||
|
||||
it("ignores directive-only when mention pattern doesn’t match self", async () => {
|
||||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||||
stdout: "ok",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
});
|
||||
const storeDir = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), "warelay-session-"),
|
||||
);
|
||||
const storePath = path.join(storeDir, "sessions.json");
|
||||
const cfg = {
|
||||
inbound: {
|
||||
groupChat: {
|
||||
mentionPatterns: ["@clawd"], // no match for @someoneelse
|
||||
},
|
||||
reply: {
|
||||
mode: "command" as const,
|
||||
command: ["echo", "{{Body}}"],
|
||||
agent: { kind: "claude" },
|
||||
session: { store: storePath },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const batchBody =
|
||||
"[Current message - respond to this]\nUser: @someoneelse /thinking high";
|
||||
|
||||
const res = await index.getReplyFromConfig(
|
||||
{ Body: batchBody, From: "group:789@g.us", To: "+447511247203" },
|
||||
undefined,
|
||||
cfg,
|
||||
runSpy,
|
||||
);
|
||||
|
||||
// Because mention doesn’t match, it’s treated as normal text and forwarded.
|
||||
expect(res?.text).toBe("ok");
|
||||
expect(runSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects invalid directive-only think level without changing state", async () => {
|
||||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||||
stdout: "ok",
|
||||
@@ -1352,7 +1493,7 @@ describe("config and templating", () => {
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(tmpStore, "utf-8"));
|
||||
const firstEntry = Object.values(persisted)[0] as { systemSent?: boolean };
|
||||
expect(firstEntry.systemSent).toBe(true);
|
||||
expect(typeof firstEntry.systemSent).toBe("boolean");
|
||||
});
|
||||
|
||||
it("keeps sending system prompt when sendSystemOnce is disabled (default)", async () => {
|
||||
|
||||
Reference in New Issue
Block a user