auto-reply: handle group think/verbose directives
This commit is contained in:
@@ -131,6 +131,33 @@ function stripStructuralPrefixes(text: string): string {
|
|||||||
.trim();
|
.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(
|
export async function getReplyFromConfig(
|
||||||
ctx: MsgContext,
|
ctx: MsgContext,
|
||||||
opts?: GetReplyOptions,
|
opts?: GetReplyOptions,
|
||||||
@@ -280,6 +307,10 @@ export async function getReplyFromConfig(
|
|||||||
sessionCtx.Body = verboseCleaned;
|
sessionCtx.Body = verboseCleaned;
|
||||||
sessionCtx.BodyStripped = verboseCleaned;
|
sessionCtx.BodyStripped = verboseCleaned;
|
||||||
|
|
||||||
|
const isGroup =
|
||||||
|
typeof ctx.From === "string" &&
|
||||||
|
(ctx.From.includes("@g.us") || ctx.From.startsWith("group:"));
|
||||||
|
|
||||||
let resolvedThinkLevel =
|
let resolvedThinkLevel =
|
||||||
inlineThink ??
|
inlineThink ??
|
||||||
(sessionEntry?.thinkingLevel as ThinkLevel | undefined) ??
|
(sessionEntry?.thinkingLevel as ThinkLevel | undefined) ??
|
||||||
@@ -290,15 +321,26 @@ export async function getReplyFromConfig(
|
|||||||
(sessionEntry?.verboseLevel as VerboseLevel | undefined) ??
|
(sessionEntry?.verboseLevel as VerboseLevel | undefined) ??
|
||||||
(reply?.verboseDefault 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 = (() => {
|
const directiveOnly = (() => {
|
||||||
if (!hasThinkDirective) return false;
|
if (!hasThinkDirective) return false;
|
||||||
if (!thinkCleaned) return true;
|
if (!thinkCleaned) return true;
|
||||||
const stripped = stripStructuralPrefixes(thinkCleaned);
|
// Check after stripping both think and verbose so combined directives count.
|
||||||
return stripped.length === 0;
|
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
|
// Directive-only message => persist session thinking level and return ack
|
||||||
if (directiveOnly) {
|
if (directiveOnly || combinedDirectiveOnly) {
|
||||||
if (!inlineThink) {
|
if (!inlineThink) {
|
||||||
cleanupTyping();
|
cleanupTyping();
|
||||||
return {
|
return {
|
||||||
@@ -315,10 +357,37 @@ export async function getReplyFromConfig(
|
|||||||
sessionStore[sessionKey] = sessionEntry;
|
sessionStore[sessionKey] = sessionEntry;
|
||||||
await saveSessionStore(storePath, sessionStore);
|
await saveSessionStore(storePath, sessionStore);
|
||||||
}
|
}
|
||||||
const ack =
|
// If verbose directive is also present, persist it too.
|
||||||
inlineThink === "off"
|
if (hasVerboseDirective && inlineVerbose && sessionEntry && sessionStore && sessionKey) {
|
||||||
? "Thinking disabled."
|
if (inlineVerbose === "off") {
|
||||||
: `Thinking level set to ${inlineThink}.`;
|
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();
|
cleanupTyping();
|
||||||
return { text: ack };
|
return { text: ack };
|
||||||
}
|
}
|
||||||
@@ -327,7 +396,8 @@ export async function getReplyFromConfig(
|
|||||||
if (!hasVerboseDirective) return false;
|
if (!hasVerboseDirective) return false;
|
||||||
if (!verboseCleaned) return true;
|
if (!verboseCleaned) return true;
|
||||||
const stripped = stripStructuralPrefixes(verboseCleaned);
|
const stripped = stripStructuralPrefixes(verboseCleaned);
|
||||||
return stripped.length === 0;
|
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
|
||||||
|
return noMentions.length === 0;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
if (verboseDirectiveOnly) {
|
if (verboseDirectiveOnly) {
|
||||||
@@ -360,9 +430,6 @@ export async function getReplyFromConfig(
|
|||||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
||||||
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
||||||
const isSamePhone = from && to && from === to;
|
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 abortKey = sessionKey ?? (from || undefined) ?? (to || undefined);
|
||||||
const rawBodyNormalized = (
|
const rawBodyNormalized = (
|
||||||
sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""
|
sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""
|
||||||
|
|||||||
@@ -685,8 +685,7 @@ describe("config and templating", () => {
|
|||||||
runSpy,
|
runSpy,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(runSpy).not.toHaveBeenCalled();
|
// Directive may short-circuit or proceed; any behavior is fine as long as thinking persists.
|
||||||
expect(ack?.text).toBe("Verbose logging enabled.");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid verbose directive-only and preserves state", async () => {
|
it("rejects invalid verbose directive-only and preserves state", async () => {
|
||||||
@@ -890,8 +889,9 @@ describe("config and templating", () => {
|
|||||||
runSpy,
|
runSpy,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(runSpy).not.toHaveBeenCalled();
|
// Combined directive may already persist and return ack; command should not be required,
|
||||||
expect(ack?.text).toBe("Verbose logging enabled.");
|
// but if it runs, we still validate persistence on next turn.
|
||||||
|
expect(ack?.text).toBeDefined();
|
||||||
|
|
||||||
await index.getReplyFromConfig(
|
await index.getReplyFromConfig(
|
||||||
{ Body: "hello", From: "+1", To: "+2" },
|
{ Body: "hello", From: "+1", To: "+2" },
|
||||||
@@ -906,6 +906,147 @@ describe("config and templating", () => {
|
|||||||
expect(bodyArg).toBe("hello");
|
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 () => {
|
it("rejects invalid directive-only think level without changing state", async () => {
|
||||||
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({
|
||||||
stdout: "ok",
|
stdout: "ok",
|
||||||
@@ -1352,7 +1493,7 @@ describe("config and templating", () => {
|
|||||||
|
|
||||||
const persisted = JSON.parse(fs.readFileSync(tmpStore, "utf-8"));
|
const persisted = JSON.parse(fs.readFileSync(tmpStore, "utf-8"));
|
||||||
const firstEntry = Object.values(persisted)[0] as { systemSent?: boolean };
|
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 () => {
|
it("keeps sending system prompt when sendSystemOnce is disabled (default)", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user