mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:38:38 +00:00
test: consolidate auto-reply unit coverage
This commit is contained in:
@@ -1,122 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
|
||||||
import { resolveCommandAuthorization } from "./command-auth.js";
|
|
||||||
import type { MsgContext } from "./templating.js";
|
|
||||||
|
|
||||||
describe("resolveCommandAuthorization", () => {
|
|
||||||
it("falls back from empty SenderId to SenderE164", () => {
|
|
||||||
const cfg = {
|
|
||||||
channels: { whatsapp: { allowFrom: ["+123"] } },
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
Provider: "whatsapp",
|
|
||||||
Surface: "whatsapp",
|
|
||||||
From: "whatsapp:+999",
|
|
||||||
SenderId: "",
|
|
||||||
SenderE164: "+123",
|
|
||||||
} as MsgContext;
|
|
||||||
|
|
||||||
const auth = resolveCommandAuthorization({
|
|
||||||
ctx,
|
|
||||||
cfg,
|
|
||||||
commandAuthorized: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(auth.senderId).toBe("+123");
|
|
||||||
expect(auth.isAuthorizedSender).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back from whitespace SenderId to SenderE164", () => {
|
|
||||||
const cfg = {
|
|
||||||
channels: { whatsapp: { allowFrom: ["+123"] } },
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
Provider: "whatsapp",
|
|
||||||
Surface: "whatsapp",
|
|
||||||
From: "whatsapp:+999",
|
|
||||||
SenderId: " ",
|
|
||||||
SenderE164: "+123",
|
|
||||||
} as MsgContext;
|
|
||||||
|
|
||||||
const auth = resolveCommandAuthorization({
|
|
||||||
ctx,
|
|
||||||
cfg,
|
|
||||||
commandAuthorized: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(auth.senderId).toBe("+123");
|
|
||||||
expect(auth.isAuthorizedSender).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to From when SenderId and SenderE164 are whitespace", () => {
|
|
||||||
const cfg = {
|
|
||||||
channels: { whatsapp: { allowFrom: ["+999"] } },
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
Provider: "whatsapp",
|
|
||||||
Surface: "whatsapp",
|
|
||||||
From: "whatsapp:+999",
|
|
||||||
SenderId: " ",
|
|
||||||
SenderE164: " ",
|
|
||||||
} as MsgContext;
|
|
||||||
|
|
||||||
const auth = resolveCommandAuthorization({
|
|
||||||
ctx,
|
|
||||||
cfg,
|
|
||||||
commandAuthorized: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(auth.senderId).toBe("+999");
|
|
||||||
expect(auth.isAuthorizedSender).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back from un-normalizable SenderId to SenderE164", () => {
|
|
||||||
const cfg = {
|
|
||||||
channels: { whatsapp: { allowFrom: ["+123"] } },
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
Provider: "whatsapp",
|
|
||||||
Surface: "whatsapp",
|
|
||||||
From: "whatsapp:+999",
|
|
||||||
SenderId: "wat",
|
|
||||||
SenderE164: "+123",
|
|
||||||
} as MsgContext;
|
|
||||||
|
|
||||||
const auth = resolveCommandAuthorization({
|
|
||||||
ctx,
|
|
||||||
cfg,
|
|
||||||
commandAuthorized: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(auth.senderId).toBe("+123");
|
|
||||||
expect(auth.isAuthorizedSender).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers SenderE164 when SenderId does not match allowFrom", () => {
|
|
||||||
const cfg = {
|
|
||||||
channels: { whatsapp: { allowFrom: ["+41796666864"] } },
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
Provider: "whatsapp",
|
|
||||||
Surface: "whatsapp",
|
|
||||||
From: "whatsapp:120363401234567890@g.us",
|
|
||||||
SenderId: "123@lid",
|
|
||||||
SenderE164: "+41796666864",
|
|
||||||
} as MsgContext;
|
|
||||||
|
|
||||||
const auth = resolveCommandAuthorization({
|
|
||||||
ctx,
|
|
||||||
cfg,
|
|
||||||
commandAuthorized: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(auth.senderId).toBe("+41796666864");
|
|
||||||
expect(auth.isAuthorizedSender).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||||
|
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||||
|
import { resolveCommandAuthorization } from "./command-auth.js";
|
||||||
import { hasControlCommand, hasInlineCommandTokens } from "./command-detection.js";
|
import { hasControlCommand, hasInlineCommandTokens } from "./command-detection.js";
|
||||||
import { listChatCommands } from "./commands-registry.js";
|
import { listChatCommands } from "./commands-registry.js";
|
||||||
import { parseActivationCommand } from "./group-activation.js";
|
import { parseActivationCommand } from "./group-activation.js";
|
||||||
import { parseSendPolicyCommand } from "./send-policy.js";
|
import { parseSendPolicyCommand } from "./send-policy.js";
|
||||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
import type { MsgContext } from "./templating.js";
|
||||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePluginRegistry(createTestRegistry([]));
|
setActivePluginRegistry(createTestRegistry([]));
|
||||||
@@ -14,6 +18,123 @@ afterEach(() => {
|
|||||||
setActivePluginRegistry(createTestRegistry([]));
|
setActivePluginRegistry(createTestRegistry([]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveCommandAuthorization", () => {
|
||||||
|
it("falls back from empty SenderId to SenderE164", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: { whatsapp: { allowFrom: ["+123"] } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
Provider: "whatsapp",
|
||||||
|
Surface: "whatsapp",
|
||||||
|
From: "whatsapp:+999",
|
||||||
|
SenderId: "",
|
||||||
|
SenderE164: "+123",
|
||||||
|
} as MsgContext;
|
||||||
|
|
||||||
|
const auth = resolveCommandAuthorization({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
commandAuthorized: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(auth.senderId).toBe("+123");
|
||||||
|
expect(auth.isAuthorizedSender).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back from whitespace SenderId to SenderE164", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: { whatsapp: { allowFrom: ["+123"] } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
Provider: "whatsapp",
|
||||||
|
Surface: "whatsapp",
|
||||||
|
From: "whatsapp:+999",
|
||||||
|
SenderId: " ",
|
||||||
|
SenderE164: "+123",
|
||||||
|
} as MsgContext;
|
||||||
|
|
||||||
|
const auth = resolveCommandAuthorization({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
commandAuthorized: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(auth.senderId).toBe("+123");
|
||||||
|
expect(auth.isAuthorizedSender).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to From when SenderId and SenderE164 are whitespace", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: { whatsapp: { allowFrom: ["+999"] } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
Provider: "whatsapp",
|
||||||
|
Surface: "whatsapp",
|
||||||
|
From: "whatsapp:+999",
|
||||||
|
SenderId: " ",
|
||||||
|
SenderE164: " ",
|
||||||
|
} as MsgContext;
|
||||||
|
|
||||||
|
const auth = resolveCommandAuthorization({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
commandAuthorized: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(auth.senderId).toBe("+999");
|
||||||
|
expect(auth.isAuthorizedSender).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back from un-normalizable SenderId to SenderE164", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: { whatsapp: { allowFrom: ["+123"] } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
Provider: "whatsapp",
|
||||||
|
Surface: "whatsapp",
|
||||||
|
From: "whatsapp:+999",
|
||||||
|
SenderId: "wat",
|
||||||
|
SenderE164: "+123",
|
||||||
|
} as MsgContext;
|
||||||
|
|
||||||
|
const auth = resolveCommandAuthorization({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
commandAuthorized: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(auth.senderId).toBe("+123");
|
||||||
|
expect(auth.isAuthorizedSender).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers SenderE164 when SenderId does not match allowFrom", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: { whatsapp: { allowFrom: ["+41796666864"] } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
Provider: "whatsapp",
|
||||||
|
Surface: "whatsapp",
|
||||||
|
From: "whatsapp:120363401234567890@g.us",
|
||||||
|
SenderId: "123@lid",
|
||||||
|
SenderE164: "+41796666864",
|
||||||
|
} as MsgContext;
|
||||||
|
|
||||||
|
const auth = resolveCommandAuthorization({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
commandAuthorized: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(auth.senderId).toBe("+41796666864");
|
||||||
|
expect(auth.isAuthorizedSender).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("control command parsing", () => {
|
describe("control command parsing", () => {
|
||||||
it("requires slash for send policy", () => {
|
it("requires slash for send policy", () => {
|
||||||
expect(parseSendPolicyCommand("/send on")).toEqual({
|
expect(parseSendPolicyCommand("/send on")).toEqual({
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
buildCommandTextFromArgs,
|
|
||||||
parseCommandArgs,
|
|
||||||
resolveCommandArgMenu,
|
|
||||||
serializeCommandArgs,
|
|
||||||
} from "./commands-registry.js";
|
|
||||||
import type { ChatCommandDefinition } from "./commands-registry.types.js";
|
|
||||||
|
|
||||||
describe("commands registry args", () => {
|
|
||||||
it("parses positional args and captureRemaining", () => {
|
|
||||||
const command: ChatCommandDefinition = {
|
|
||||||
key: "debug",
|
|
||||||
description: "debug",
|
|
||||||
textAliases: [],
|
|
||||||
scope: "both",
|
|
||||||
argsParsing: "positional",
|
|
||||||
args: [
|
|
||||||
{ name: "action", description: "action", type: "string" },
|
|
||||||
{ name: "path", description: "path", type: "string" },
|
|
||||||
{ name: "value", description: "value", type: "string", captureRemaining: true },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const args = parseCommandArgs(command, "set foo bar baz");
|
|
||||||
expect(args?.values).toEqual({ action: "set", path: "foo", value: "bar baz" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serializes args via raw first, then values", () => {
|
|
||||||
const command: ChatCommandDefinition = {
|
|
||||||
key: "model",
|
|
||||||
description: "model",
|
|
||||||
textAliases: [],
|
|
||||||
scope: "both",
|
|
||||||
argsParsing: "positional",
|
|
||||||
args: [{ name: "model", description: "model", type: "string", captureRemaining: true }],
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(serializeCommandArgs(command, { raw: "gpt-5.2-codex" })).toBe("gpt-5.2-codex");
|
|
||||||
expect(serializeCommandArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
|
|
||||||
"gpt-5.2-codex",
|
|
||||||
);
|
|
||||||
expect(buildCommandTextFromArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
|
|
||||||
"/model gpt-5.2-codex",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves auto arg menus when missing a choice arg", () => {
|
|
||||||
const command: ChatCommandDefinition = {
|
|
||||||
key: "usage",
|
|
||||||
description: "usage",
|
|
||||||
textAliases: [],
|
|
||||||
scope: "both",
|
|
||||||
argsMenu: "auto",
|
|
||||||
argsParsing: "positional",
|
|
||||||
args: [
|
|
||||||
{
|
|
||||||
name: "mode",
|
|
||||||
description: "mode",
|
|
||||||
type: "string",
|
|
||||||
choices: ["off", "tokens", "full", "cost"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
|
||||||
expect(menu?.arg.name).toBe("mode");
|
|
||||||
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not show menus when arg already provided", () => {
|
|
||||||
const command: ChatCommandDefinition = {
|
|
||||||
key: "usage",
|
|
||||||
description: "usage",
|
|
||||||
textAliases: [],
|
|
||||||
scope: "both",
|
|
||||||
argsMenu: "auto",
|
|
||||||
argsParsing: "positional",
|
|
||||||
args: [
|
|
||||||
{
|
|
||||||
name: "mode",
|
|
||||||
description: "mode",
|
|
||||||
type: "string",
|
|
||||||
choices: ["off", "tokens", "full", "cost"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const menu = resolveCommandArgMenu({
|
|
||||||
command,
|
|
||||||
args: { values: { mode: "tokens" } },
|
|
||||||
cfg: {} as never,
|
|
||||||
});
|
|
||||||
expect(menu).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves function-based choices with a default provider/model context", () => {
|
|
||||||
let seen: { provider: string; model: string; commandKey: string; argName: string } | null =
|
|
||||||
null;
|
|
||||||
|
|
||||||
const command: ChatCommandDefinition = {
|
|
||||||
key: "think",
|
|
||||||
description: "think",
|
|
||||||
textAliases: [],
|
|
||||||
scope: "both",
|
|
||||||
argsMenu: "auto",
|
|
||||||
argsParsing: "positional",
|
|
||||||
args: [
|
|
||||||
{
|
|
||||||
name: "level",
|
|
||||||
description: "level",
|
|
||||||
type: "string",
|
|
||||||
choices: ({ provider, model, command, arg }) => {
|
|
||||||
seen = { provider, model, commandKey: command.key, argName: arg.name };
|
|
||||||
return ["low", "high"];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
|
||||||
expect(menu?.arg.name).toBe("level");
|
|
||||||
expect(menu?.choices).toEqual(["low", "high"]);
|
|
||||||
expect(seen?.commandKey).toBe("think");
|
|
||||||
expect(seen?.argName).toBe("level");
|
|
||||||
expect(seen?.provider).toBeTruthy();
|
|
||||||
expect(seen?.model).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not show menus when args were provided as raw text only", () => {
|
|
||||||
const command: ChatCommandDefinition = {
|
|
||||||
key: "usage",
|
|
||||||
description: "usage",
|
|
||||||
textAliases: [],
|
|
||||||
scope: "both",
|
|
||||||
argsMenu: "auto",
|
|
||||||
argsParsing: "none",
|
|
||||||
args: [
|
|
||||||
{
|
|
||||||
name: "mode",
|
|
||||||
description: "on or off",
|
|
||||||
type: "string",
|
|
||||||
choices: ["off", "tokens", "full", "cost"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const menu = resolveCommandArgMenu({
|
|
||||||
command,
|
|
||||||
args: { raw: "on" },
|
|
||||||
cfg: {} as never,
|
|
||||||
});
|
|
||||||
expect(menu).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,14 +2,19 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildCommandText,
|
buildCommandText,
|
||||||
|
buildCommandTextFromArgs,
|
||||||
getCommandDetection,
|
getCommandDetection,
|
||||||
listChatCommands,
|
listChatCommands,
|
||||||
listChatCommandsForConfig,
|
listChatCommandsForConfig,
|
||||||
listNativeCommandSpecs,
|
listNativeCommandSpecs,
|
||||||
listNativeCommandSpecsForConfig,
|
listNativeCommandSpecsForConfig,
|
||||||
normalizeCommandBody,
|
normalizeCommandBody,
|
||||||
|
parseCommandArgs,
|
||||||
|
resolveCommandArgMenu,
|
||||||
|
serializeCommandArgs,
|
||||||
shouldHandleTextCommands,
|
shouldHandleTextCommands,
|
||||||
} from "./commands-registry.js";
|
} from "./commands-registry.js";
|
||||||
|
import type { ChatCommandDefinition } from "./commands-registry.types.js";
|
||||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||||
|
|
||||||
@@ -154,3 +159,150 @@ describe("commands registry", () => {
|
|||||||
expect(normalizeCommandBody("/dock_telegram")).toBe("/dock-telegram");
|
expect(normalizeCommandBody("/dock_telegram")).toBe("/dock-telegram");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("commands registry args", () => {
|
||||||
|
it("parses positional args and captureRemaining", () => {
|
||||||
|
const command: ChatCommandDefinition = {
|
||||||
|
key: "debug",
|
||||||
|
description: "debug",
|
||||||
|
textAliases: [],
|
||||||
|
scope: "both",
|
||||||
|
argsParsing: "positional",
|
||||||
|
args: [
|
||||||
|
{ name: "action", description: "action", type: "string" },
|
||||||
|
{ name: "path", description: "path", type: "string" },
|
||||||
|
{ name: "value", description: "value", type: "string", captureRemaining: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const args = parseCommandArgs(command, "set foo bar baz");
|
||||||
|
expect(args?.values).toEqual({ action: "set", path: "foo", value: "bar baz" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes args via raw first, then values", () => {
|
||||||
|
const command: ChatCommandDefinition = {
|
||||||
|
key: "model",
|
||||||
|
description: "model",
|
||||||
|
textAliases: [],
|
||||||
|
scope: "both",
|
||||||
|
argsParsing: "positional",
|
||||||
|
args: [{ name: "model", description: "model", type: "string", captureRemaining: true }],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(serializeCommandArgs(command, { raw: "gpt-5.2-codex" })).toBe("gpt-5.2-codex");
|
||||||
|
expect(serializeCommandArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
|
||||||
|
"gpt-5.2-codex",
|
||||||
|
);
|
||||||
|
expect(buildCommandTextFromArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
|
||||||
|
"/model gpt-5.2-codex",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves auto arg menus when missing a choice arg", () => {
|
||||||
|
const command: ChatCommandDefinition = {
|
||||||
|
key: "usage",
|
||||||
|
description: "usage",
|
||||||
|
textAliases: [],
|
||||||
|
scope: "both",
|
||||||
|
argsMenu: "auto",
|
||||||
|
argsParsing: "positional",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: "mode",
|
||||||
|
description: "mode",
|
||||||
|
type: "string",
|
||||||
|
choices: ["off", "tokens", "full", "cost"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
||||||
|
expect(menu?.arg.name).toBe("mode");
|
||||||
|
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show menus when arg already provided", () => {
|
||||||
|
const command: ChatCommandDefinition = {
|
||||||
|
key: "usage",
|
||||||
|
description: "usage",
|
||||||
|
textAliases: [],
|
||||||
|
scope: "both",
|
||||||
|
argsMenu: "auto",
|
||||||
|
argsParsing: "positional",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: "mode",
|
||||||
|
description: "mode",
|
||||||
|
type: "string",
|
||||||
|
choices: ["off", "tokens", "full", "cost"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const menu = resolveCommandArgMenu({
|
||||||
|
command,
|
||||||
|
args: { values: { mode: "tokens" } },
|
||||||
|
cfg: {} as never,
|
||||||
|
});
|
||||||
|
expect(menu).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves function-based choices with a default provider/model context", () => {
|
||||||
|
let seen: { provider: string; model: string; commandKey: string; argName: string } | null =
|
||||||
|
null;
|
||||||
|
|
||||||
|
const command: ChatCommandDefinition = {
|
||||||
|
key: "think",
|
||||||
|
description: "think",
|
||||||
|
textAliases: [],
|
||||||
|
scope: "both",
|
||||||
|
argsMenu: "auto",
|
||||||
|
argsParsing: "positional",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: "level",
|
||||||
|
description: "level",
|
||||||
|
type: "string",
|
||||||
|
choices: ({ provider, model, command, arg }) => {
|
||||||
|
seen = { provider, model, commandKey: command.key, argName: arg.name };
|
||||||
|
return ["low", "high"];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
||||||
|
expect(menu?.arg.name).toBe("level");
|
||||||
|
expect(menu?.choices).toEqual(["low", "high"]);
|
||||||
|
expect(seen?.commandKey).toBe("think");
|
||||||
|
expect(seen?.argName).toBe("level");
|
||||||
|
expect(seen?.provider).toBeTruthy();
|
||||||
|
expect(seen?.model).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show menus when args were provided as raw text only", () => {
|
||||||
|
const command: ChatCommandDefinition = {
|
||||||
|
key: "usage",
|
||||||
|
description: "usage",
|
||||||
|
textAliases: [],
|
||||||
|
scope: "both",
|
||||||
|
argsMenu: "auto",
|
||||||
|
argsParsing: "none",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
name: "mode",
|
||||||
|
description: "on or off",
|
||||||
|
type: "string",
|
||||||
|
choices: ["off", "tokens", "full", "cost"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const menu = resolveCommandArgMenu({
|
||||||
|
command,
|
||||||
|
args: { raw: "on" },
|
||||||
|
cfg: {} as never,
|
||||||
|
});
|
||||||
|
expect(menu).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { createInboundDebouncer } from "./inbound-debounce.js";
|
|
||||||
|
|
||||||
describe("createInboundDebouncer", () => {
|
|
||||||
it("debounces and combines items", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const calls: Array<string[]> = [];
|
|
||||||
|
|
||||||
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
|
||||||
debounceMs: 10,
|
|
||||||
buildKey: (item) => item.key,
|
|
||||||
onFlush: async (items) => {
|
|
||||||
calls.push(items.map((entry) => entry.id));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await debouncer.enqueue({ key: "a", id: "1" });
|
|
||||||
await debouncer.enqueue({ key: "a", id: "2" });
|
|
||||||
|
|
||||||
expect(calls).toEqual([]);
|
|
||||||
await vi.advanceTimersByTimeAsync(10);
|
|
||||||
expect(calls).toEqual([["1", "2"]]);
|
|
||||||
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flushes buffered items before non-debounced item", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const calls: Array<string[]> = [];
|
|
||||||
|
|
||||||
const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
|
|
||||||
debounceMs: 50,
|
|
||||||
buildKey: (item) => item.key,
|
|
||||||
shouldDebounce: (item) => item.debounce,
|
|
||||||
onFlush: async (items) => {
|
|
||||||
calls.push(items.map((entry) => entry.id));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await debouncer.enqueue({ key: "a", id: "1", debounce: true });
|
|
||||||
await debouncer.enqueue({ key: "a", id: "2", debounce: false });
|
|
||||||
|
|
||||||
expect(calls).toEqual([["1"], ["2"]]);
|
|
||||||
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
402
src/auto-reply/inbound.test.ts
Normal file
402
src/auto-reply/inbound.test.ts
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import type { GroupKeyResolution } from "../config/sessions.js";
|
||||||
|
import { createInboundDebouncer } from "./inbound-debounce.js";
|
||||||
|
import { applyTemplate, type MsgContext, type TemplateContext } from "./templating.js";
|
||||||
|
import { finalizeInboundContext } from "./reply/inbound-context.js";
|
||||||
|
import {
|
||||||
|
buildInboundDedupeKey,
|
||||||
|
resetInboundDedupe,
|
||||||
|
shouldSkipDuplicateInbound,
|
||||||
|
} from "./reply/inbound-dedupe.js";
|
||||||
|
import { formatInboundBodyWithSenderMeta } from "./reply/inbound-sender-meta.js";
|
||||||
|
import { normalizeInboundTextNewlines } from "./reply/inbound-text.js";
|
||||||
|
import { resolveGroupRequireMention } from "./reply/groups.js";
|
||||||
|
import {
|
||||||
|
buildMentionRegexes,
|
||||||
|
matchesMentionPatterns,
|
||||||
|
normalizeMentionText,
|
||||||
|
} from "./reply/mentions.js";
|
||||||
|
import { initSessionState } from "./reply/session.js";
|
||||||
|
|
||||||
|
describe("applyTemplate", () => {
|
||||||
|
it("renders primitive values", () => {
|
||||||
|
const ctx = { MessageSid: "sid", IsNewSession: "no" } as TemplateContext;
|
||||||
|
const overrides = ctx as Record<string, unknown>;
|
||||||
|
overrides.MessageSid = 42;
|
||||||
|
overrides.IsNewSession = true;
|
||||||
|
|
||||||
|
expect(applyTemplate("sid={{MessageSid}} new={{IsNewSession}}", ctx)).toBe("sid=42 new=true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders arrays of primitives", () => {
|
||||||
|
const ctx = { MediaPaths: ["a"] } as TemplateContext;
|
||||||
|
(ctx as Record<string, unknown>).MediaPaths = ["a", 2, true, null, { ok: false }];
|
||||||
|
|
||||||
|
expect(applyTemplate("paths={{MediaPaths}}", ctx)).toBe("paths=a,2,true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops object values", () => {
|
||||||
|
const ctx: TemplateContext = { CommandArgs: { raw: "go" } };
|
||||||
|
|
||||||
|
expect(applyTemplate("args={{CommandArgs}}", ctx)).toBe("args=");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders missing placeholders as empty", () => {
|
||||||
|
const ctx: TemplateContext = {};
|
||||||
|
|
||||||
|
expect(applyTemplate("missing={{Missing}}", ctx)).toBe("missing=");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeInboundTextNewlines", () => {
|
||||||
|
it("keeps real newlines", () => {
|
||||||
|
expect(normalizeInboundTextNewlines("a\nb")).toBe("a\nb");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes CRLF/CR to LF", () => {
|
||||||
|
expect(normalizeInboundTextNewlines("a\r\nb")).toBe("a\nb");
|
||||||
|
expect(normalizeInboundTextNewlines("a\rb")).toBe("a\nb");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes literal \\n to newlines when no real newlines exist", () => {
|
||||||
|
expect(normalizeInboundTextNewlines("a\\nb")).toBe("a\nb");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("finalizeInboundContext", () => {
|
||||||
|
it("fills BodyForAgent/BodyForCommands and normalizes newlines", () => {
|
||||||
|
const ctx: MsgContext = {
|
||||||
|
Body: "a\\nb\r\nc",
|
||||||
|
RawBody: "raw\\nline",
|
||||||
|
ChatType: "channel",
|
||||||
|
From: "whatsapp:group:123@g.us",
|
||||||
|
GroupSubject: "Test",
|
||||||
|
};
|
||||||
|
|
||||||
|
const out = finalizeInboundContext(ctx);
|
||||||
|
expect(out.Body).toBe("a\nb\nc");
|
||||||
|
expect(out.RawBody).toBe("raw\nline");
|
||||||
|
expect(out.BodyForAgent).toBe("a\nb\nc");
|
||||||
|
expect(out.BodyForCommands).toBe("raw\nline");
|
||||||
|
expect(out.CommandAuthorized).toBe(false);
|
||||||
|
expect(out.ChatType).toBe("channel");
|
||||||
|
expect(out.ConversationLabel).toContain("Test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can force BodyForCommands to follow updated CommandBody", () => {
|
||||||
|
const ctx: MsgContext = {
|
||||||
|
Body: "base",
|
||||||
|
BodyForCommands: "<media:audio>",
|
||||||
|
CommandBody: "say hi",
|
||||||
|
From: "signal:+15550001111",
|
||||||
|
ChatType: "direct",
|
||||||
|
};
|
||||||
|
|
||||||
|
finalizeInboundContext(ctx, { forceBodyForCommands: true });
|
||||||
|
expect(ctx.BodyForCommands).toBe("say hi");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatInboundBodyWithSenderMeta", () => {
|
||||||
|
it("does nothing for direct messages", () => {
|
||||||
|
const ctx: MsgContext = { ChatType: "direct", SenderName: "Alice", SenderId: "A1" };
|
||||||
|
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe("[X] hi");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends a sender meta line for non-direct messages", () => {
|
||||||
|
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||||
|
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe(
|
||||||
|
"[X] hi\n[from: Alice (A1)]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers SenderE164 in the label when present", () => {
|
||||||
|
const ctx: MsgContext = {
|
||||||
|
ChatType: "group",
|
||||||
|
SenderName: "Bob",
|
||||||
|
SenderId: "bob@s.whatsapp.net",
|
||||||
|
SenderE164: "+222",
|
||||||
|
};
|
||||||
|
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe(
|
||||||
|
"[X] hi\n[from: Bob (+222)]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends with a real newline even if the body contains literal \\n", () => {
|
||||||
|
const ctx: MsgContext = { ChatType: "group", SenderName: "Bob", SenderId: "+222" };
|
||||||
|
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] one\\n[X] two" })).toBe(
|
||||||
|
"[X] one\\n[X] two\n[from: Bob (+222)]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate a sender meta line when one is already present", () => {
|
||||||
|
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||||
|
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi\n[from: Alice (A1)]" })).toBe(
|
||||||
|
"[X] hi\n[from: Alice (A1)]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not append when the body already includes a sender prefix", () => {
|
||||||
|
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||||
|
expect(formatInboundBodyWithSenderMeta({ ctx, body: "Alice (A1): hi" })).toBe("Alice (A1): hi");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not append when the sender prefix follows an envelope header", () => {
|
||||||
|
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||||
|
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[Signal Group] Alice (A1): hi" })).toBe(
|
||||||
|
"[Signal Group] Alice (A1): hi",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inbound dedupe", () => {
|
||||||
|
it("builds a stable key when MessageSid is present", () => {
|
||||||
|
const ctx: MsgContext = {
|
||||||
|
Provider: "telegram",
|
||||||
|
OriginatingChannel: "telegram",
|
||||||
|
OriginatingTo: "telegram:123",
|
||||||
|
MessageSid: "42",
|
||||||
|
};
|
||||||
|
expect(buildInboundDedupeKey(ctx)).toBe("telegram|telegram:123|42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips duplicates with the same key", () => {
|
||||||
|
resetInboundDedupe();
|
||||||
|
const ctx: MsgContext = {
|
||||||
|
Provider: "whatsapp",
|
||||||
|
OriginatingChannel: "whatsapp",
|
||||||
|
OriginatingTo: "whatsapp:+1555",
|
||||||
|
MessageSid: "msg-1",
|
||||||
|
};
|
||||||
|
expect(shouldSkipDuplicateInbound(ctx, { now: 100 })).toBe(false);
|
||||||
|
expect(shouldSkipDuplicateInbound(ctx, { now: 200 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not dedupe when the peer changes", () => {
|
||||||
|
resetInboundDedupe();
|
||||||
|
const base: MsgContext = {
|
||||||
|
Provider: "whatsapp",
|
||||||
|
OriginatingChannel: "whatsapp",
|
||||||
|
MessageSid: "msg-1",
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+1000" }, { now: 100 }),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+2000" }, { now: 200 }),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not dedupe across session keys", () => {
|
||||||
|
resetInboundDedupe();
|
||||||
|
const base: MsgContext = {
|
||||||
|
Provider: "whatsapp",
|
||||||
|
OriginatingChannel: "whatsapp",
|
||||||
|
OriginatingTo: "whatsapp:+1555",
|
||||||
|
MessageSid: "msg-1",
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 100 }),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:bravo:main" }, { now: 200 }),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 300 }),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createInboundDebouncer", () => {
|
||||||
|
it("debounces and combines items", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const calls: Array<string[]> = [];
|
||||||
|
|
||||||
|
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
||||||
|
debounceMs: 10,
|
||||||
|
buildKey: (item) => item.key,
|
||||||
|
onFlush: async (items) => {
|
||||||
|
calls.push(items.map((entry) => entry.id));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await debouncer.enqueue({ key: "a", id: "1" });
|
||||||
|
await debouncer.enqueue({ key: "a", id: "2" });
|
||||||
|
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
|
expect(calls).toEqual([["1", "2"]]);
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flushes buffered items before non-debounced item", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const calls: Array<string[]> = [];
|
||||||
|
|
||||||
|
const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
|
||||||
|
debounceMs: 50,
|
||||||
|
buildKey: (item) => item.key,
|
||||||
|
shouldDebounce: (item) => item.debounce,
|
||||||
|
onFlush: async (items) => {
|
||||||
|
calls.push(items.map((entry) => entry.id));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await debouncer.enqueue({ key: "a", id: "1", debounce: true });
|
||||||
|
await debouncer.enqueue({ key: "a", id: "2", debounce: false });
|
||||||
|
|
||||||
|
expect(calls).toEqual([["1"], ["2"]]);
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initSessionState sender meta", () => {
|
||||||
|
it("injects sender meta into BodyStripped for group chats", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-"));
|
||||||
|
const storePath = path.join(root, "sessions.json");
|
||||||
|
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
||||||
|
|
||||||
|
const result = await initSessionState({
|
||||||
|
ctx: {
|
||||||
|
Body: "[WhatsApp 123@g.us] ping",
|
||||||
|
ChatType: "group",
|
||||||
|
SenderName: "Bob",
|
||||||
|
SenderE164: "+222",
|
||||||
|
SenderId: "222@s.whatsapp.net",
|
||||||
|
SessionKey: "agent:main:whatsapp:group:123@g.us",
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
commandAuthorized: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp 123@g.us] ping\n[from: Bob (+222)]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not inject sender meta for direct chats", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-direct-"));
|
||||||
|
const storePath = path.join(root, "sessions.json");
|
||||||
|
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
||||||
|
|
||||||
|
const result = await initSessionState({
|
||||||
|
ctx: {
|
||||||
|
Body: "[WhatsApp +1] ping",
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderName: "Bob",
|
||||||
|
SenderE164: "+222",
|
||||||
|
SessionKey: "agent:main:whatsapp:dm:+222",
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
commandAuthorized: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp +1] ping");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mention helpers", () => {
|
||||||
|
it("builds regexes and skips invalid patterns", () => {
|
||||||
|
const regexes = buildMentionRegexes({
|
||||||
|
messages: {
|
||||||
|
groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(regexes).toHaveLength(1);
|
||||||
|
expect(regexes[0]?.test("clawd")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes zero-width characters", () => {
|
||||||
|
expect(normalizeMentionText("cl\u200bawd")).toBe("clawd");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches patterns case-insensitively", () => {
|
||||||
|
const regexes = buildMentionRegexes({
|
||||||
|
messages: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } },
|
||||||
|
});
|
||||||
|
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses per-agent mention patterns when configured", () => {
|
||||||
|
const regexes = buildMentionRegexes(
|
||||||
|
{
|
||||||
|
messages: {
|
||||||
|
groupChat: { mentionPatterns: ["\\bglobal\\b"] },
|
||||||
|
},
|
||||||
|
agents: {
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
id: "work",
|
||||||
|
groupChat: { mentionPatterns: ["\\bworkbot\\b"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"work",
|
||||||
|
);
|
||||||
|
expect(matchesMentionPatterns("workbot: hi", regexes)).toBe(true);
|
||||||
|
expect(matchesMentionPatterns("global: hi", regexes)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveGroupRequireMention", () => {
|
||||||
|
it("respects Discord guild/channel requireMention settings", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
discord: {
|
||||||
|
guilds: {
|
||||||
|
"145": {
|
||||||
|
requireMention: false,
|
||||||
|
channels: {
|
||||||
|
general: { allow: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ctx: TemplateContext = {
|
||||||
|
Provider: "discord",
|
||||||
|
From: "discord:group:123",
|
||||||
|
GroupChannel: "#general",
|
||||||
|
GroupSpace: "145",
|
||||||
|
};
|
||||||
|
const groupResolution: GroupKeyResolution = {
|
||||||
|
channel: "discord",
|
||||||
|
id: "123",
|
||||||
|
chatType: "group",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects Slack channel requireMention settings", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
channels: {
|
||||||
|
C123: { requireMention: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ctx: TemplateContext = {
|
||||||
|
Provider: "slack",
|
||||||
|
From: "slack:channel:C123",
|
||||||
|
GroupSubject: "#general",
|
||||||
|
};
|
||||||
|
const groupResolution: GroupKeyResolution = {
|
||||||
|
channel: "slack",
|
||||||
|
id: "C123",
|
||||||
|
chatType: "group",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,174 +2,79 @@ import fs from "node:fs/promises";
|
|||||||
import { basename, join } from "node:path";
|
import { basename, join } from "node:path";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||||
|
import type { MsgContext, TemplateContext } from "./templating.js";
|
||||||
|
|
||||||
vi.mock("../agents/pi-embedded.js", () => ({
|
const sandboxMocks = vi.hoisted(() => ({
|
||||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
ensureSandboxWorkspaceForSession: vi.fn(),
|
||||||
compactEmbeddedPiSession: vi.fn(),
|
|
||||||
runEmbeddedPiAgent: vi.fn(),
|
|
||||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
|
||||||
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
|
|
||||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
|
||||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const usageMocks = vi.hoisted(() => ({
|
vi.mock("../agents/sandbox.js", () => sandboxMocks);
|
||||||
loadProviderUsageSummary: vi.fn().mockResolvedValue({
|
|
||||||
updatedAt: 0,
|
|
||||||
providers: [],
|
|
||||||
}),
|
|
||||||
formatUsageSummaryLine: vi.fn().mockReturnValue("📊 Usage: Claude 80% left"),
|
|
||||||
resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../infra/provider-usage.js", () => usageMocks);
|
|
||||||
|
|
||||||
const modelCatalogMocks = vi.hoisted(() => ({
|
|
||||||
loadModelCatalog: vi.fn().mockResolvedValue([
|
|
||||||
{
|
|
||||||
provider: "anthropic",
|
|
||||||
id: "claude-opus-4-5",
|
|
||||||
name: "Claude Opus 4.5",
|
|
||||||
contextWindow: 200000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "openrouter",
|
|
||||||
id: "anthropic/claude-opus-4-5",
|
|
||||||
name: "Claude Opus 4.5 (OpenRouter)",
|
|
||||||
contextWindow: 200000,
|
|
||||||
},
|
|
||||||
{ provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" },
|
|
||||||
{ provider: "openai", id: "gpt-5.2", name: "GPT-5.2" },
|
|
||||||
{ provider: "openai-codex", id: "gpt-5.2", name: "GPT-5.2 (Codex)" },
|
|
||||||
{ provider: "minimax", id: "MiniMax-M2.1", name: "MiniMax M2.1" },
|
|
||||||
]),
|
|
||||||
resetModelCatalogCacheForTest: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
|
|
||||||
|
|
||||||
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
|
||||||
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
|
||||||
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
|
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
|
||||||
import { resolveAgentIdFromSessionKey, resolveSessionKey } from "../config/sessions.js";
|
import { stageSandboxMedia } from "./reply/stage-sandbox-media.js";
|
||||||
import { getReplyFromConfig } from "./reply.js";
|
|
||||||
|
|
||||||
const _MAIN_SESSION_KEY = "agent:main:main";
|
|
||||||
|
|
||||||
const webMocks = vi.hoisted(() => ({
|
|
||||||
webAuthExists: vi.fn().mockResolvedValue(true),
|
|
||||||
getWebAuthAgeMs: vi.fn().mockReturnValue(120_000),
|
|
||||||
readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../web/session.js", () => webMocks);
|
|
||||||
|
|
||||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||||
return withTempHomeBase(
|
return withTempHomeBase(async (home) => await fn(home), { prefix: "clawdbot-triggers-" });
|
||||||
async (home) => {
|
|
||||||
vi.mocked(runEmbeddedPiAgent).mockClear();
|
|
||||||
vi.mocked(abortEmbeddedPiRun).mockClear();
|
|
||||||
return await fn(home);
|
|
||||||
},
|
|
||||||
{ prefix: "clawdbot-triggers-" },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _makeCfg(home: string) {
|
|
||||||
return {
|
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
model: "anthropic/claude-opus-4-5",
|
|
||||||
workspace: join(home, "clawd"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
channels: {
|
|
||||||
whatsapp: {
|
|
||||||
allowFrom: ["*"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
session: { store: join(home, "sessions.json") },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("trigger handling", () => {
|
describe("stageSandboxMedia", () => {
|
||||||
it("stages inbound media into the sandbox workspace", { timeout: 60_000 }, async () => {
|
it("stages inbound media into the sandbox workspace", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const inboundDir = join(home, ".clawdbot", "media", "inbound");
|
const inboundDir = join(home, ".clawdbot", "media", "inbound");
|
||||||
await fs.mkdir(inboundDir, { recursive: true });
|
await fs.mkdir(inboundDir, { recursive: true });
|
||||||
const mediaPath = join(inboundDir, "photo.jpg");
|
const mediaPath = join(inboundDir, "photo.jpg");
|
||||||
await fs.writeFile(mediaPath, "test");
|
await fs.writeFile(mediaPath, "test");
|
||||||
|
|
||||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
const sandboxDir = join(home, "sandboxes", "session");
|
||||||
payloads: [{ text: "ok" }],
|
vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({
|
||||||
meta: {
|
workspaceDir: sandboxDir,
|
||||||
durationMs: 1,
|
containerWorkdir: "/work",
|
||||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const cfg = {
|
const ctx: MsgContext = {
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
model: "anthropic/claude-opus-4-5",
|
|
||||||
workspace: join(home, "clawd"),
|
|
||||||
sandbox: {
|
|
||||||
mode: "non-main" as const,
|
|
||||||
workspaceRoot: join(home, "sandboxes"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
channels: {
|
|
||||||
whatsapp: {
|
|
||||||
allowFrom: ["*"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
store: join(home, "sessions.json"),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
Body: "hi",
|
Body: "hi",
|
||||||
From: "whatsapp:group:demo",
|
From: "whatsapp:group:demo",
|
||||||
To: "+2000",
|
To: "+2000",
|
||||||
ChatType: "group" as const,
|
ChatType: "group",
|
||||||
Provider: "whatsapp" as const,
|
Provider: "whatsapp",
|
||||||
MediaPath: mediaPath,
|
MediaPath: mediaPath,
|
||||||
MediaType: "image/jpeg",
|
MediaType: "image/jpeg",
|
||||||
MediaUrl: mediaPath,
|
MediaUrl: mediaPath,
|
||||||
};
|
};
|
||||||
|
const sessionCtx: TemplateContext = { ...ctx };
|
||||||
|
|
||||||
const res = await getReplyFromConfig(ctx, {}, cfg);
|
await stageSandboxMedia({
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
|
||||||
expect(text).toBe("ok");
|
|
||||||
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
|
||||||
|
|
||||||
const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
|
|
||||||
const stagedPath = `media/inbound/${basename(mediaPath)}`;
|
|
||||||
expect(prompt).toContain(stagedPath);
|
|
||||||
expect(prompt).not.toContain(mediaPath);
|
|
||||||
|
|
||||||
const sessionKey = resolveSessionKey(
|
|
||||||
cfg.session?.scope ?? "per-sender",
|
|
||||||
ctx,
|
ctx,
|
||||||
cfg.session?.mainKey,
|
sessionCtx,
|
||||||
);
|
cfg: {
|
||||||
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
agents: {
|
||||||
const sandbox = await ensureSandboxWorkspaceForSession({
|
defaults: {
|
||||||
config: cfg,
|
model: "anthropic/claude-opus-4-5",
|
||||||
sessionKey,
|
workspace: join(home, "clawd"),
|
||||||
workspaceDir: resolveAgentWorkspaceDir(cfg, agentId),
|
sandbox: {
|
||||||
|
mode: "non-main",
|
||||||
|
workspaceRoot: join(home, "sandboxes"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
|
session: { store: join(home, "sessions.json") },
|
||||||
|
},
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
workspaceDir: join(home, "clawd"),
|
||||||
});
|
});
|
||||||
expect(sandbox).not.toBeNull();
|
|
||||||
if (!sandbox) {
|
const stagedPath = `media/inbound/${basename(mediaPath)}`;
|
||||||
throw new Error("Expected sandbox to be set");
|
expect(ctx.MediaPath).toBe(stagedPath);
|
||||||
}
|
expect(sessionCtx.MediaPath).toBe(stagedPath);
|
||||||
const stagedFullPath = join(sandbox.workspaceDir, "media", "inbound", basename(mediaPath));
|
expect(ctx.MediaUrl).toBe(stagedPath);
|
||||||
|
expect(sessionCtx.MediaUrl).toBe(stagedPath);
|
||||||
|
|
||||||
|
const stagedFullPath = join(sandboxDir, "media", "inbound", basename(mediaPath));
|
||||||
await expect(fs.stat(stagedFullPath)).resolves.toBeTruthy();
|
await expect(fs.stat(stagedFullPath)).resolves.toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { parseAudioTag } from "./audio-tags.js";
|
|
||||||
|
|
||||||
describe("parseAudioTag", () => {
|
|
||||||
it("detects audio_as_voice and strips the tag", () => {
|
|
||||||
const result = parseAudioTag("Hello [[audio_as_voice]] world");
|
|
||||||
expect(result.audioAsVoice).toBe(true);
|
|
||||||
expect(result.hadTag).toBe(true);
|
|
||||||
expect(result.text).toBe("Hello world");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty output for missing text", () => {
|
|
||||||
const result = parseAudioTag(undefined);
|
|
||||||
expect(result.audioAsVoice).toBe(false);
|
|
||||||
expect(result.hadTag).toBe(false);
|
|
||||||
expect(result.text).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes tag-only messages", () => {
|
|
||||||
const result = parseAudioTag("[[audio_as_voice]]");
|
|
||||||
expect(result.audioAsVoice).toBe(true);
|
|
||||||
expect(result.text).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { createBlockReplyCoalescer } from "./block-reply-coalescer.js";
|
|
||||||
|
|
||||||
describe("block reply coalescer", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("coalesces chunks within the idle window", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const flushes: string[] = [];
|
|
||||||
const coalescer = createBlockReplyCoalescer({
|
|
||||||
config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " },
|
|
||||||
shouldAbort: () => false,
|
|
||||||
onFlush: (payload) => {
|
|
||||||
flushes.push(payload.text ?? "");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
coalescer.enqueue({ text: "Hello" });
|
|
||||||
coalescer.enqueue({ text: "world" });
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(100);
|
|
||||||
expect(flushes).toEqual(["Hello world"]);
|
|
||||||
coalescer.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("waits until minChars before idle flush", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const flushes: string[] = [];
|
|
||||||
const coalescer = createBlockReplyCoalescer({
|
|
||||||
config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " },
|
|
||||||
shouldAbort: () => false,
|
|
||||||
onFlush: (payload) => {
|
|
||||||
flushes.push(payload.text ?? "");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
coalescer.enqueue({ text: "short" });
|
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
|
||||||
expect(flushes).toEqual([]);
|
|
||||||
|
|
||||||
coalescer.enqueue({ text: "message" });
|
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
|
||||||
expect(flushes).toEqual(["short message"]);
|
|
||||||
coalescer.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flushes buffered text before media payloads", () => {
|
|
||||||
const flushes: Array<{ text?: string; mediaUrls?: string[] }> = [];
|
|
||||||
const coalescer = createBlockReplyCoalescer({
|
|
||||||
config: { minChars: 1, maxChars: 200, idleMs: 0, joiner: " " },
|
|
||||||
shouldAbort: () => false,
|
|
||||||
onFlush: (payload) => {
|
|
||||||
flushes.push({
|
|
||||||
text: payload.text,
|
|
||||||
mediaUrls: payload.mediaUrls,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
coalescer.enqueue({ text: "Hello" });
|
|
||||||
coalescer.enqueue({ text: "world" });
|
|
||||||
coalescer.enqueue({ mediaUrls: ["https://example.com/a.png"] });
|
|
||||||
void coalescer.flush({ force: true });
|
|
||||||
|
|
||||||
expect(flushes[0].text).toBe("Hello world");
|
|
||||||
expect(flushes[1].mediaUrls).toEqual(["https://example.com/a.png"]);
|
|
||||||
coalescer.stop();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
|
||||||
import type { MsgContext } from "../templating.js";
|
|
||||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
|
||||||
import { parseInlineDirectives } from "./directive-handling.js";
|
|
||||||
|
|
||||||
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
|
||||||
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
|
|
||||||
const writeConfigFileMock = vi.hoisted(() => vi.fn());
|
|
||||||
|
|
||||||
vi.mock("../../config/config.js", async () => {
|
|
||||||
const actual =
|
|
||||||
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
|
||||||
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
|
|
||||||
writeConfigFile: writeConfigFileMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
|
|
||||||
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
|
||||||
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
|
||||||
|
|
||||||
vi.mock("../../pairing/pairing-store.js", async () => {
|
|
||||||
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
|
|
||||||
"../../pairing/pairing-store.js",
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
readChannelAllowFromStore: readChannelAllowFromStoreMock,
|
|
||||||
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
|
|
||||||
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("../../channels/plugins/pairing.js", async () => {
|
|
||||||
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
|
|
||||||
"../../channels/plugins/pairing.js",
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
listPairingChannels: () => ["telegram"],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
|
|
||||||
const ctx = {
|
|
||||||
Body: commandBody,
|
|
||||||
CommandBody: commandBody,
|
|
||||||
CommandSource: "text",
|
|
||||||
CommandAuthorized: true,
|
|
||||||
Provider: "telegram",
|
|
||||||
Surface: "telegram",
|
|
||||||
...ctxOverrides,
|
|
||||||
} as MsgContext;
|
|
||||||
|
|
||||||
const command = buildCommandContext({
|
|
||||||
ctx,
|
|
||||||
cfg,
|
|
||||||
isGroup: false,
|
|
||||||
triggerBodyNormalized: commandBody.trim().toLowerCase(),
|
|
||||||
commandAuthorized: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ctx,
|
|
||||||
cfg,
|
|
||||||
command,
|
|
||||||
directives: parseInlineDirectives(commandBody),
|
|
||||||
elevated: { enabled: true, allowed: true, failures: [] },
|
|
||||||
sessionKey: "agent:main:main",
|
|
||||||
workspaceDir: "/tmp",
|
|
||||||
defaultGroupActivation: () => "mention",
|
|
||||||
resolvedVerboseLevel: "off" as const,
|
|
||||||
resolvedReasoningLevel: "off" as const,
|
|
||||||
resolveDefaultThinkingLevel: async () => undefined,
|
|
||||||
provider: "telegram",
|
|
||||||
model: "test-model",
|
|
||||||
contextTokens: 0,
|
|
||||||
isGroup: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("handleCommands /allowlist", () => {
|
|
||||||
it("lists config + store allowFrom entries", async () => {
|
|
||||||
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
|
|
||||||
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
const params = buildParams("/allowlist list dm", cfg);
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("Channel: telegram");
|
|
||||||
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
|
|
||||||
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds entries to config and pairing store", async () => {
|
|
||||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
|
||||||
valid: true,
|
|
||||||
parsed: {
|
|
||||||
channels: { telegram: { allowFrom: ["123"] } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
|
||||||
ok: true,
|
|
||||||
config,
|
|
||||||
}));
|
|
||||||
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
|
|
||||||
changed: true,
|
|
||||||
allowFrom: ["123", "789"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true, config: true },
|
|
||||||
channels: { telegram: { allowFrom: ["123"] } },
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
const params = buildParams("/allowlist add dm 789", cfg);
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
channels: { telegram: { allowFrom: ["123", "789"] } },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
|
|
||||||
channel: "telegram",
|
|
||||||
entry: "789",
|
|
||||||
});
|
|
||||||
expect(result.reply?.text).toContain("DM allowlist added");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
|
||||||
import type { MsgContext } from "../templating.js";
|
|
||||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
|
||||||
import { parseInlineDirectives } from "./directive-handling.js";
|
|
||||||
|
|
||||||
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
|
|
||||||
const ctx = {
|
|
||||||
Body: commandBody,
|
|
||||||
CommandBody: commandBody,
|
|
||||||
CommandSource: "text",
|
|
||||||
CommandAuthorized: true,
|
|
||||||
Provider: "whatsapp",
|
|
||||||
Surface: "whatsapp",
|
|
||||||
...ctxOverrides,
|
|
||||||
} as MsgContext;
|
|
||||||
|
|
||||||
const command = buildCommandContext({
|
|
||||||
ctx,
|
|
||||||
cfg,
|
|
||||||
isGroup: false,
|
|
||||||
triggerBodyNormalized: commandBody.trim().toLowerCase(),
|
|
||||||
commandAuthorized: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ctx,
|
|
||||||
cfg,
|
|
||||||
command,
|
|
||||||
directives: parseInlineDirectives(commandBody),
|
|
||||||
elevated: { enabled: true, allowed: true, failures: [] },
|
|
||||||
sessionKey: "agent:main:main",
|
|
||||||
workspaceDir: "/tmp",
|
|
||||||
defaultGroupActivation: () => "mention",
|
|
||||||
resolvedVerboseLevel: "off" as const,
|
|
||||||
resolvedReasoningLevel: "off" as const,
|
|
||||||
resolveDefaultThinkingLevel: async () => undefined,
|
|
||||||
provider: "whatsapp",
|
|
||||||
model: "test-model",
|
|
||||||
contextTokens: 0,
|
|
||||||
isGroup: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("handleCommands /config configWrites gating", () => {
|
|
||||||
it("blocks /config set when channel config writes are disabled", async () => {
|
|
||||||
const cfg = {
|
|
||||||
commands: { config: true, text: true },
|
|
||||||
channels: { whatsapp: { allowFrom: ["*"], configWrites: false } },
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
const params = buildParams('/config set messages.ackReaction=":)"', cfg);
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("Config writes are disabled");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
125
src/auto-reply/reply/commands-parsing.test.ts
Normal file
125
src/auto-reply/reply/commands-parsing.test.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import type { MsgContext } from "../templating.js";
|
||||||
|
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||||
|
import { extractMessageText } from "./commands-subagents.js";
|
||||||
|
import { parseConfigCommand } from "./config-commands.js";
|
||||||
|
import { parseDebugCommand } from "./debug-commands.js";
|
||||||
|
import { parseInlineDirectives } from "./directive-handling.js";
|
||||||
|
|
||||||
|
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
|
||||||
|
const ctx = {
|
||||||
|
Body: commandBody,
|
||||||
|
CommandBody: commandBody,
|
||||||
|
CommandSource: "text",
|
||||||
|
CommandAuthorized: true,
|
||||||
|
Provider: "whatsapp",
|
||||||
|
Surface: "whatsapp",
|
||||||
|
...ctxOverrides,
|
||||||
|
} as MsgContext;
|
||||||
|
|
||||||
|
const command = buildCommandContext({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
isGroup: false,
|
||||||
|
triggerBodyNormalized: commandBody.trim().toLowerCase(),
|
||||||
|
commandAuthorized: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
command,
|
||||||
|
directives: parseInlineDirectives(commandBody),
|
||||||
|
elevated: { enabled: true, allowed: true, failures: [] },
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
workspaceDir: "/tmp",
|
||||||
|
defaultGroupActivation: () => "mention",
|
||||||
|
resolvedVerboseLevel: "off" as const,
|
||||||
|
resolvedReasoningLevel: "off" as const,
|
||||||
|
resolveDefaultThinkingLevel: async () => undefined,
|
||||||
|
provider: "whatsapp",
|
||||||
|
model: "test-model",
|
||||||
|
contextTokens: 0,
|
||||||
|
isGroup: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("parseConfigCommand", () => {
|
||||||
|
it("parses show/unset", () => {
|
||||||
|
expect(parseConfigCommand("/config")).toEqual({ action: "show" });
|
||||||
|
expect(parseConfigCommand("/config show")).toEqual({
|
||||||
|
action: "show",
|
||||||
|
path: undefined,
|
||||||
|
});
|
||||||
|
expect(parseConfigCommand("/config show foo.bar")).toEqual({
|
||||||
|
action: "show",
|
||||||
|
path: "foo.bar",
|
||||||
|
});
|
||||||
|
expect(parseConfigCommand("/config get foo.bar")).toEqual({
|
||||||
|
action: "show",
|
||||||
|
path: "foo.bar",
|
||||||
|
});
|
||||||
|
expect(parseConfigCommand("/config unset foo.bar")).toEqual({
|
||||||
|
action: "unset",
|
||||||
|
path: "foo.bar",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses set with JSON", () => {
|
||||||
|
const cmd = parseConfigCommand('/config set foo={"a":1}');
|
||||||
|
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseDebugCommand", () => {
|
||||||
|
it("parses show/reset", () => {
|
||||||
|
expect(parseDebugCommand("/debug")).toEqual({ action: "show" });
|
||||||
|
expect(parseDebugCommand("/debug show")).toEqual({ action: "show" });
|
||||||
|
expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses set with JSON", () => {
|
||||||
|
const cmd = parseDebugCommand('/debug set foo={"a":1}');
|
||||||
|
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses unset", () => {
|
||||||
|
const cmd = parseDebugCommand("/debug unset foo.bar");
|
||||||
|
expect(cmd).toEqual({ action: "unset", path: "foo.bar" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractMessageText", () => {
|
||||||
|
it("preserves user text that looks like tool call markers", () => {
|
||||||
|
const message = {
|
||||||
|
role: "user",
|
||||||
|
content: "Here [Tool Call: foo (ID: 1)] ok",
|
||||||
|
};
|
||||||
|
const result = extractMessageText(message);
|
||||||
|
expect(result?.text).toContain("[Tool Call: foo (ID: 1)]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes assistant tool call markers", () => {
|
||||||
|
const message = {
|
||||||
|
role: "assistant",
|
||||||
|
content: "Here [Tool Call: foo (ID: 1)] ok",
|
||||||
|
};
|
||||||
|
const result = extractMessageText(message);
|
||||||
|
expect(result?.text).toBe("Here ok");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleCommands /config configWrites gating", () => {
|
||||||
|
it("blocks /config set when channel config writes are disabled", async () => {
|
||||||
|
const cfg = {
|
||||||
|
commands: { config: true, text: true },
|
||||||
|
channels: { whatsapp: { allowFrom: ["*"], configWrites: false } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
const params = buildParams('/config set messages.ackReaction=":)"', cfg);
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Config writes are disabled");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,47 @@ import type { MsgContext } from "../templating.js";
|
|||||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||||
import { parseInlineDirectives } from "./directive-handling.js";
|
import { parseInlineDirectives } from "./directive-handling.js";
|
||||||
|
|
||||||
|
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
||||||
|
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
|
||||||
|
const writeConfigFileMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../../config/config.js", async () => {
|
||||||
|
const actual =
|
||||||
|
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
||||||
|
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
|
||||||
|
writeConfigFile: writeConfigFileMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
|
||||||
|
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||||
|
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../../pairing/pairing-store.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
|
||||||
|
"../../pairing/pairing-store.js",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
readChannelAllowFromStore: readChannelAllowFromStoreMock,
|
||||||
|
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
|
||||||
|
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../channels/plugins/pairing.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
|
||||||
|
"../../channels/plugins/pairing.js",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
listPairingChannels: () => ["telegram"],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("../../agents/model-catalog.js", () => ({
|
vi.mock("../../agents/model-catalog.js", () => ({
|
||||||
loadModelCatalog: vi.fn(async () => [
|
loadModelCatalog: vi.fn(async () => [
|
||||||
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" },
|
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" },
|
||||||
@@ -46,17 +87,70 @@ function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Pa
|
|||||||
resolvedVerboseLevel: "off" as const,
|
resolvedVerboseLevel: "off" as const,
|
||||||
resolvedReasoningLevel: "off" as const,
|
resolvedReasoningLevel: "off" as const,
|
||||||
resolveDefaultThinkingLevel: async () => undefined,
|
resolveDefaultThinkingLevel: async () => undefined,
|
||||||
provider: "anthropic",
|
provider: "telegram",
|
||||||
model: "claude-opus-4-5",
|
model: "test-model",
|
||||||
contextTokens: 16000,
|
contextTokens: 0,
|
||||||
isGroup: false,
|
isGroup: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("handleCommands /allowlist", () => {
|
||||||
|
it("lists config + store allowFrom entries", async () => {
|
||||||
|
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
const params = buildParams("/allowlist list dm", cfg);
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Channel: telegram");
|
||||||
|
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
|
||||||
|
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds entries to config and pairing store", async () => {
|
||||||
|
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||||
|
valid: true,
|
||||||
|
parsed: {
|
||||||
|
channels: { telegram: { allowFrom: ["123"] } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||||
|
ok: true,
|
||||||
|
config,
|
||||||
|
}));
|
||||||
|
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
|
||||||
|
changed: true,
|
||||||
|
allowFrom: ["123", "789"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true, config: true },
|
||||||
|
channels: { telegram: { allowFrom: ["123"] } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
const params = buildParams("/allowlist add dm 789", cfg);
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
channels: { telegram: { allowFrom: ["123", "789"] } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
|
||||||
|
channel: "telegram",
|
||||||
|
entry: "789",
|
||||||
|
});
|
||||||
|
expect(result.reply?.text).toContain("DM allowlist added");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("/models command", () => {
|
describe("/models command", () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
commands: { text: true },
|
commands: { text: true },
|
||||||
// allowlist is empty => allowAny, but still okay for listing
|
|
||||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
|
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
|
||||||
} as unknown as ClawdbotConfig;
|
} as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { extractMessageText } from "./commands-subagents.js";
|
|
||||||
|
|
||||||
describe("extractMessageText", () => {
|
|
||||||
it("preserves user text that looks like tool call markers", () => {
|
|
||||||
const message = {
|
|
||||||
role: "user",
|
|
||||||
content: "Here [Tool Call: foo (ID: 1)] ok",
|
|
||||||
};
|
|
||||||
const result = extractMessageText(message);
|
|
||||||
expect(result?.text).toContain("[Tool Call: foo (ID: 1)]");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sanitizes assistant tool call markers", () => {
|
|
||||||
const message = {
|
|
||||||
role: "assistant",
|
|
||||||
content: "Here [Tool Call: foo (ID: 1)] ok",
|
|
||||||
};
|
|
||||||
const result = extractMessageText(message);
|
|
||||||
expect(result?.text).toBe("Here ok");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { parseConfigCommand } from "./config-commands.js";
|
|
||||||
|
|
||||||
describe("parseConfigCommand", () => {
|
|
||||||
it("parses show/unset", () => {
|
|
||||||
expect(parseConfigCommand("/config")).toEqual({ action: "show" });
|
|
||||||
expect(parseConfigCommand("/config show")).toEqual({
|
|
||||||
action: "show",
|
|
||||||
path: undefined,
|
|
||||||
});
|
|
||||||
expect(parseConfigCommand("/config show foo.bar")).toEqual({
|
|
||||||
action: "show",
|
|
||||||
path: "foo.bar",
|
|
||||||
});
|
|
||||||
expect(parseConfigCommand("/config get foo.bar")).toEqual({
|
|
||||||
action: "show",
|
|
||||||
path: "foo.bar",
|
|
||||||
});
|
|
||||||
expect(parseConfigCommand("/config unset foo.bar")).toEqual({
|
|
||||||
action: "unset",
|
|
||||||
path: "foo.bar",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses set with JSON", () => {
|
|
||||||
const cmd = parseConfigCommand('/config set foo={"a":1}');
|
|
||||||
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { parseDebugCommand } from "./debug-commands.js";
|
|
||||||
|
|
||||||
describe("parseDebugCommand", () => {
|
|
||||||
it("parses show/reset", () => {
|
|
||||||
expect(parseDebugCommand("/debug")).toEqual({ action: "show" });
|
|
||||||
expect(parseDebugCommand("/debug show")).toEqual({ action: "show" });
|
|
||||||
expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses set with JSON", () => {
|
|
||||||
const cmd = parseDebugCommand('/debug set foo={"a":1}');
|
|
||||||
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses unset", () => {
|
|
||||||
const cmd = parseDebugCommand("/debug unset foo.bar");
|
|
||||||
expect(cmd).toEqual({ action: "unset", path: "foo.bar" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import type { ModelAliasIndex } from "../../agents/model-selection.js";
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
|
||||||
import { parseInlineDirectives } from "./directive-handling.js";
|
|
||||||
import {
|
|
||||||
maybeHandleModelDirectiveInfo,
|
|
||||||
resolveModelSelectionFromDirective,
|
|
||||||
} from "./directive-handling.model.js";
|
|
||||||
|
|
||||||
function baseAliasIndex(): ModelAliasIndex {
|
|
||||||
return { byAlias: new Map(), byKey: new Map() };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("/model chat UX", () => {
|
|
||||||
it("shows summary for /model with no args", async () => {
|
|
||||||
const directives = parseInlineDirectives("/model");
|
|
||||||
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
|
|
||||||
|
|
||||||
const reply = await maybeHandleModelDirectiveInfo({
|
|
||||||
directives,
|
|
||||||
cfg,
|
|
||||||
agentDir: "/tmp/agent",
|
|
||||||
activeAgentId: "main",
|
|
||||||
provider: "anthropic",
|
|
||||||
model: "claude-opus-4-5",
|
|
||||||
defaultProvider: "anthropic",
|
|
||||||
defaultModel: "claude-opus-4-5",
|
|
||||||
aliasIndex: baseAliasIndex(),
|
|
||||||
allowedModelCatalog: [],
|
|
||||||
resetModelOverride: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(reply?.text).toContain("Current:");
|
|
||||||
expect(reply?.text).toContain("Browse: /models");
|
|
||||||
expect(reply?.text).toContain("Switch: /model <provider/model>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("auto-applies closest match for typos", () => {
|
|
||||||
const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5");
|
|
||||||
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
|
|
||||||
|
|
||||||
const resolved = resolveModelSelectionFromDirective({
|
|
||||||
directives,
|
|
||||||
cfg,
|
|
||||||
agentDir: "/tmp/agent",
|
|
||||||
defaultProvider: "anthropic",
|
|
||||||
defaultModel: "claude-opus-4-5",
|
|
||||||
aliasIndex: baseAliasIndex(),
|
|
||||||
allowedModelKeys: new Set(["anthropic/claude-opus-4-5"]),
|
|
||||||
allowedModelCatalog: [{ provider: "anthropic", id: "claude-opus-4-5" }],
|
|
||||||
provider: "anthropic",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(resolved.modelSelection).toEqual({
|
|
||||||
provider: "anthropic",
|
|
||||||
model: "claude-opus-4-5",
|
|
||||||
isDefault: true,
|
|
||||||
});
|
|
||||||
expect(resolved.errorText).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -5,8 +5,12 @@ import type { ClawdbotConfig } from "../../config/config.js";
|
|||||||
import type { SessionEntry } from "../../config/sessions.js";
|
import type { SessionEntry } from "../../config/sessions.js";
|
||||||
import { parseInlineDirectives } from "./directive-handling.js";
|
import { parseInlineDirectives } from "./directive-handling.js";
|
||||||
import { handleDirectiveOnly } from "./directive-handling.impl.js";
|
import { handleDirectiveOnly } from "./directive-handling.impl.js";
|
||||||
|
import {
|
||||||
|
maybeHandleModelDirectiveInfo,
|
||||||
|
resolveModelSelectionFromDirective,
|
||||||
|
} from "./directive-handling.model.js";
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies for directive handling persistence.
|
||||||
vi.mock("../../agents/agent-scope.js", () => ({
|
vi.mock("../../agents/agent-scope.js", () => ({
|
||||||
resolveAgentConfig: vi.fn(() => ({})),
|
resolveAgentConfig: vi.fn(() => ({})),
|
||||||
resolveAgentDir: vi.fn(() => "/tmp/agent"),
|
resolveAgentDir: vi.fn(() => "/tmp/agent"),
|
||||||
@@ -36,6 +40,55 @@ function baseConfig(): ClawdbotConfig {
|
|||||||
} as unknown as ClawdbotConfig;
|
} as unknown as ClawdbotConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("/model chat UX", () => {
|
||||||
|
it("shows summary for /model with no args", async () => {
|
||||||
|
const directives = parseInlineDirectives("/model");
|
||||||
|
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
|
const reply = await maybeHandleModelDirectiveInfo({
|
||||||
|
directives,
|
||||||
|
cfg,
|
||||||
|
agentDir: "/tmp/agent",
|
||||||
|
activeAgentId: "main",
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude-opus-4-5",
|
||||||
|
defaultProvider: "anthropic",
|
||||||
|
defaultModel: "claude-opus-4-5",
|
||||||
|
aliasIndex: baseAliasIndex(),
|
||||||
|
allowedModelCatalog: [],
|
||||||
|
resetModelOverride: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(reply?.text).toContain("Current:");
|
||||||
|
expect(reply?.text).toContain("Browse: /models");
|
||||||
|
expect(reply?.text).toContain("Switch: /model <provider/model>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-applies closest match for typos", () => {
|
||||||
|
const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5");
|
||||||
|
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
|
const resolved = resolveModelSelectionFromDirective({
|
||||||
|
directives,
|
||||||
|
cfg,
|
||||||
|
agentDir: "/tmp/agent",
|
||||||
|
defaultProvider: "anthropic",
|
||||||
|
defaultModel: "claude-opus-4-5",
|
||||||
|
aliasIndex: baseAliasIndex(),
|
||||||
|
allowedModelKeys: new Set(["anthropic/claude-opus-4-5"]),
|
||||||
|
allowedModelCatalog: [{ provider: "anthropic", id: "claude-opus-4-5" }],
|
||||||
|
provider: "anthropic",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved.modelSelection).toEqual({
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude-opus-4-5",
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
expect(resolved.errorText).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
|
describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
|
||||||
const allowedModelKeys = new Set(["anthropic/claude-opus-4-5", "openai/gpt-4o"]);
|
const allowedModelKeys = new Set(["anthropic/claude-opus-4-5", "openai/gpt-4o"]);
|
||||||
const allowedModelCatalog = [
|
const allowedModelCatalog = [
|
||||||
@@ -106,7 +159,6 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
|
|||||||
formatModelSwitchEvent: (label) => `Switched to ${label}`,
|
formatModelSwitchEvent: (label) => `Switched to ${label}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// No model directive = no model message
|
|
||||||
expect(result?.text ?? "").not.toContain("Model set to");
|
expect(result?.text ?? "").not.toContain("Model set to");
|
||||||
expect(result?.text ?? "").not.toContain("failed");
|
expect(result?.text ?? "").not.toContain("failed");
|
||||||
});
|
});
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import type { SessionEntry } from "../../config/sessions.js";
|
|
||||||
import type { FollowupRun } from "./queue.js";
|
|
||||||
import { createMockTypingController } from "./test-helpers.js";
|
|
||||||
|
|
||||||
const runEmbeddedPiAgentMock = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("../../agents/model-fallback.js", () => ({
|
|
||||||
runWithModelFallback: async ({
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
run,
|
|
||||||
}: {
|
|
||||||
provider: string;
|
|
||||||
model: string;
|
|
||||||
run: (provider: string, model: string) => Promise<unknown>;
|
|
||||||
}) => ({
|
|
||||||
result: await run(provider, model),
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../agents/pi-embedded.js", () => ({
|
|
||||||
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { createFollowupRunner } from "./followup-runner.js";
|
|
||||||
|
|
||||||
describe("createFollowupRunner compaction", () => {
|
|
||||||
it("adds verbose auto-compaction notice and tracks count", async () => {
|
|
||||||
const storePath = path.join(
|
|
||||||
await fs.mkdtemp(path.join(tmpdir(), "clawdbot-compaction-")),
|
|
||||||
"sessions.json",
|
|
||||||
);
|
|
||||||
const sessionEntry: SessionEntry = {
|
|
||||||
sessionId: "session",
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
};
|
|
||||||
const sessionStore: Record<string, SessionEntry> = {
|
|
||||||
main: sessionEntry,
|
|
||||||
};
|
|
||||||
const onBlockReply = vi.fn(async () => {});
|
|
||||||
|
|
||||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
|
||||||
async (params: {
|
|
||||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
|
||||||
}) => {
|
|
||||||
params.onAgentEvent?.({
|
|
||||||
stream: "compaction",
|
|
||||||
data: { phase: "end", willRetry: false },
|
|
||||||
});
|
|
||||||
return { payloads: [{ text: "final" }], meta: {} };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const runner = createFollowupRunner({
|
|
||||||
opts: { onBlockReply },
|
|
||||||
typing: createMockTypingController(),
|
|
||||||
typingMode: "instant",
|
|
||||||
sessionEntry,
|
|
||||||
sessionStore,
|
|
||||||
sessionKey: "main",
|
|
||||||
storePath,
|
|
||||||
defaultModel: "anthropic/claude-opus-4-5",
|
|
||||||
});
|
|
||||||
|
|
||||||
const queued = {
|
|
||||||
prompt: "hello",
|
|
||||||
summaryLine: "hello",
|
|
||||||
enqueuedAt: Date.now(),
|
|
||||||
run: {
|
|
||||||
sessionId: "session",
|
|
||||||
sessionKey: "main",
|
|
||||||
messageProvider: "whatsapp",
|
|
||||||
sessionFile: "/tmp/session.jsonl",
|
|
||||||
workspaceDir: "/tmp",
|
|
||||||
config: {},
|
|
||||||
skillsSnapshot: {},
|
|
||||||
provider: "anthropic",
|
|
||||||
model: "claude",
|
|
||||||
thinkLevel: "low",
|
|
||||||
verboseLevel: "on",
|
|
||||||
elevatedLevel: "off",
|
|
||||||
bashElevated: {
|
|
||||||
enabled: false,
|
|
||||||
allowed: false,
|
|
||||||
defaultLevel: "off",
|
|
||||||
},
|
|
||||||
timeoutMs: 1_000,
|
|
||||||
blockReplyBreak: "message_end",
|
|
||||||
},
|
|
||||||
} as FollowupRun;
|
|
||||||
|
|
||||||
await runner(queued);
|
|
||||||
|
|
||||||
expect(onBlockReply).toHaveBeenCalled();
|
|
||||||
expect(onBlockReply.mock.calls[0][0].text).toContain("Auto-compaction complete");
|
|
||||||
expect(sessionStore.main.compactionCount).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { SessionEntry } from "../../config/sessions.js";
|
||||||
import type { FollowupRun } from "./queue.js";
|
import type { FollowupRun } from "./queue.js";
|
||||||
import { createMockTypingController } from "./test-helpers.js";
|
import { createMockTypingController } from "./test-helpers.js";
|
||||||
|
|
||||||
@@ -57,6 +61,79 @@ const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun =>
|
|||||||
},
|
},
|
||||||
}) as FollowupRun;
|
}) as FollowupRun;
|
||||||
|
|
||||||
|
describe("createFollowupRunner compaction", () => {
|
||||||
|
it("adds verbose auto-compaction notice and tracks count", async () => {
|
||||||
|
const storePath = path.join(
|
||||||
|
await fs.mkdtemp(path.join(tmpdir(), "clawdbot-compaction-")),
|
||||||
|
"sessions.json",
|
||||||
|
);
|
||||||
|
const sessionEntry: SessionEntry = {
|
||||||
|
sessionId: "session",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
const sessionStore: Record<string, SessionEntry> = {
|
||||||
|
main: sessionEntry,
|
||||||
|
};
|
||||||
|
const onBlockReply = vi.fn(async () => {});
|
||||||
|
|
||||||
|
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||||
|
async (params: {
|
||||||
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
||||||
|
}) => {
|
||||||
|
params.onAgentEvent?.({
|
||||||
|
stream: "compaction",
|
||||||
|
data: { phase: "end", willRetry: false },
|
||||||
|
});
|
||||||
|
return { payloads: [{ text: "final" }], meta: {} };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const runner = createFollowupRunner({
|
||||||
|
opts: { onBlockReply },
|
||||||
|
typing: createMockTypingController(),
|
||||||
|
typingMode: "instant",
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey: "main",
|
||||||
|
storePath,
|
||||||
|
defaultModel: "anthropic/claude-opus-4-5",
|
||||||
|
});
|
||||||
|
|
||||||
|
const queued = {
|
||||||
|
prompt: "hello",
|
||||||
|
summaryLine: "hello",
|
||||||
|
enqueuedAt: Date.now(),
|
||||||
|
run: {
|
||||||
|
sessionId: "session",
|
||||||
|
sessionKey: "main",
|
||||||
|
messageProvider: "whatsapp",
|
||||||
|
sessionFile: "/tmp/session.jsonl",
|
||||||
|
workspaceDir: "/tmp",
|
||||||
|
config: {},
|
||||||
|
skillsSnapshot: {},
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude",
|
||||||
|
thinkLevel: "low",
|
||||||
|
verboseLevel: "on",
|
||||||
|
elevatedLevel: "off",
|
||||||
|
bashElevated: {
|
||||||
|
enabled: false,
|
||||||
|
allowed: false,
|
||||||
|
defaultLevel: "off",
|
||||||
|
},
|
||||||
|
timeoutMs: 1_000,
|
||||||
|
blockReplyBreak: "message_end",
|
||||||
|
},
|
||||||
|
} as FollowupRun;
|
||||||
|
|
||||||
|
await runner(queued);
|
||||||
|
|
||||||
|
expect(onBlockReply).toHaveBeenCalled();
|
||||||
|
expect(onBlockReply.mock.calls[0][0].text).toContain("Auto-compaction complete");
|
||||||
|
expect(sessionStore.main.compactionCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("createFollowupRunner messaging tool dedupe", () => {
|
describe("createFollowupRunner messaging tool dedupe", () => {
|
||||||
it("drops payloads already sent via messaging tool", async () => {
|
it("drops payloads already sent via messaging tool", async () => {
|
||||||
const onBlockReply = vi.fn(async () => {});
|
const onBlockReply = vi.fn(async () => {});
|
||||||
185
src/auto-reply/reply/formatting.test.ts
Normal file
185
src/auto-reply/reply/formatting.test.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { parseAudioTag } from "./audio-tags.js";
|
||||||
|
import { createBlockReplyCoalescer } from "./block-reply-coalescer.js";
|
||||||
|
import { createReplyReferencePlanner } from "./reply-reference.js";
|
||||||
|
import { createStreamingDirectiveAccumulator } from "./streaming-directives.js";
|
||||||
|
|
||||||
|
describe("parseAudioTag", () => {
|
||||||
|
it("detects audio_as_voice and strips the tag", () => {
|
||||||
|
const result = parseAudioTag("Hello [[audio_as_voice]] world");
|
||||||
|
expect(result.audioAsVoice).toBe(true);
|
||||||
|
expect(result.hadTag).toBe(true);
|
||||||
|
expect(result.text).toBe("Hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty output for missing text", () => {
|
||||||
|
const result = parseAudioTag(undefined);
|
||||||
|
expect(result.audioAsVoice).toBe(false);
|
||||||
|
expect(result.hadTag).toBe(false);
|
||||||
|
expect(result.text).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes tag-only messages", () => {
|
||||||
|
const result = parseAudioTag("[[audio_as_voice]]");
|
||||||
|
expect(result.audioAsVoice).toBe(true);
|
||||||
|
expect(result.text).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("block reply coalescer", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coalesces chunks within the idle window", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const flushes: string[] = [];
|
||||||
|
const coalescer = createBlockReplyCoalescer({
|
||||||
|
config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " },
|
||||||
|
shouldAbort: () => false,
|
||||||
|
onFlush: (payload) => {
|
||||||
|
flushes.push(payload.text ?? "");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
coalescer.enqueue({ text: "Hello" });
|
||||||
|
coalescer.enqueue({ text: "world" });
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(flushes).toEqual(["Hello world"]);
|
||||||
|
coalescer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits until minChars before idle flush", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const flushes: string[] = [];
|
||||||
|
const coalescer = createBlockReplyCoalescer({
|
||||||
|
config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " },
|
||||||
|
shouldAbort: () => false,
|
||||||
|
onFlush: (payload) => {
|
||||||
|
flushes.push(payload.text ?? "");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
coalescer.enqueue({ text: "short" });
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
expect(flushes).toEqual([]);
|
||||||
|
|
||||||
|
coalescer.enqueue({ text: "message" });
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
expect(flushes).toEqual(["short message"]);
|
||||||
|
coalescer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flushes buffered text before media payloads", () => {
|
||||||
|
const flushes: Array<{ text?: string; mediaUrls?: string[] }> = [];
|
||||||
|
const coalescer = createBlockReplyCoalescer({
|
||||||
|
config: { minChars: 1, maxChars: 200, idleMs: 0, joiner: " " },
|
||||||
|
shouldAbort: () => false,
|
||||||
|
onFlush: (payload) => {
|
||||||
|
flushes.push({
|
||||||
|
text: payload.text,
|
||||||
|
mediaUrls: payload.mediaUrls,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
coalescer.enqueue({ text: "Hello" });
|
||||||
|
coalescer.enqueue({ text: "world" });
|
||||||
|
coalescer.enqueue({ mediaUrls: ["https://example.com/a.png"] });
|
||||||
|
void coalescer.flush({ force: true });
|
||||||
|
|
||||||
|
expect(flushes[0].text).toBe("Hello world");
|
||||||
|
expect(flushes[1].mediaUrls).toEqual(["https://example.com/a.png"]);
|
||||||
|
coalescer.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createReplyReferencePlanner", () => {
|
||||||
|
it("disables references when mode is off", () => {
|
||||||
|
const planner = createReplyReferencePlanner({
|
||||||
|
replyToMode: "off",
|
||||||
|
startId: "parent",
|
||||||
|
});
|
||||||
|
expect(planner.use()).toBeUndefined();
|
||||||
|
expect(planner.hasReplied()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses startId once when mode is first", () => {
|
||||||
|
const planner = createReplyReferencePlanner({
|
||||||
|
replyToMode: "first",
|
||||||
|
startId: "parent",
|
||||||
|
});
|
||||||
|
expect(planner.use()).toBe("parent");
|
||||||
|
expect(planner.hasReplied()).toBe(true);
|
||||||
|
planner.markSent();
|
||||||
|
expect(planner.use()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns startId for every call when mode is all", () => {
|
||||||
|
const planner = createReplyReferencePlanner({
|
||||||
|
replyToMode: "all",
|
||||||
|
startId: "parent",
|
||||||
|
});
|
||||||
|
expect(planner.use()).toBe("parent");
|
||||||
|
expect(planner.use()).toBe("parent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers existing thread id regardless of mode", () => {
|
||||||
|
const planner = createReplyReferencePlanner({
|
||||||
|
replyToMode: "off",
|
||||||
|
existingId: "thread-1",
|
||||||
|
startId: "parent",
|
||||||
|
});
|
||||||
|
expect(planner.use()).toBe("thread-1");
|
||||||
|
expect(planner.hasReplied()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors allowReference=false", () => {
|
||||||
|
const planner = createReplyReferencePlanner({
|
||||||
|
replyToMode: "all",
|
||||||
|
startId: "parent",
|
||||||
|
allowReference: false,
|
||||||
|
});
|
||||||
|
expect(planner.use()).toBeUndefined();
|
||||||
|
expect(planner.hasReplied()).toBe(false);
|
||||||
|
planner.markSent();
|
||||||
|
expect(planner.hasReplied()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createStreamingDirectiveAccumulator", () => {
|
||||||
|
it("stashes reply_to_current until a renderable chunk arrives", () => {
|
||||||
|
const accumulator = createStreamingDirectiveAccumulator();
|
||||||
|
|
||||||
|
expect(accumulator.consume("[[reply_to_current]]")).toBeNull();
|
||||||
|
|
||||||
|
const result = accumulator.consume("Hello");
|
||||||
|
expect(result?.text).toBe("Hello");
|
||||||
|
expect(result?.replyToCurrent).toBe(true);
|
||||||
|
expect(result?.replyToTag).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles reply tags split across chunks", () => {
|
||||||
|
const accumulator = createStreamingDirectiveAccumulator();
|
||||||
|
|
||||||
|
expect(accumulator.consume("[[reply_to_")).toBeNull();
|
||||||
|
|
||||||
|
const result = accumulator.consume("current]] Yo");
|
||||||
|
expect(result?.text).toBe("Yo");
|
||||||
|
expect(result?.replyToCurrent).toBe(true);
|
||||||
|
expect(result?.replyToTag).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates explicit reply ids across chunks", () => {
|
||||||
|
const accumulator = createStreamingDirectiveAccumulator();
|
||||||
|
|
||||||
|
expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull();
|
||||||
|
|
||||||
|
const result = accumulator.consume("Hi");
|
||||||
|
expect(result?.text).toBe("Hi");
|
||||||
|
expect(result?.replyToId).toBe("abc-123");
|
||||||
|
expect(result?.replyToTag).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
|
||||||
import type { GroupKeyResolution } from "../../config/sessions.js";
|
|
||||||
import type { TemplateContext } from "../templating.js";
|
|
||||||
import { resolveGroupRequireMention } from "./groups.js";
|
|
||||||
|
|
||||||
describe("resolveGroupRequireMention", () => {
|
|
||||||
it("respects Discord guild/channel requireMention settings", () => {
|
|
||||||
const cfg: ClawdbotConfig = {
|
|
||||||
channels: {
|
|
||||||
discord: {
|
|
||||||
guilds: {
|
|
||||||
"145": {
|
|
||||||
requireMention: false,
|
|
||||||
channels: {
|
|
||||||
general: { allow: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const ctx: TemplateContext = {
|
|
||||||
Provider: "discord",
|
|
||||||
From: "discord:group:123",
|
|
||||||
GroupChannel: "#general",
|
|
||||||
GroupSpace: "145",
|
|
||||||
};
|
|
||||||
const groupResolution: GroupKeyResolution = {
|
|
||||||
channel: "discord",
|
|
||||||
id: "123",
|
|
||||||
chatType: "group",
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("respects Slack channel requireMention settings", () => {
|
|
||||||
const cfg: ClawdbotConfig = {
|
|
||||||
channels: {
|
|
||||||
slack: {
|
|
||||||
channels: {
|
|
||||||
C123: { requireMention: false },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const ctx: TemplateContext = {
|
|
||||||
Provider: "slack",
|
|
||||||
From: "slack:channel:C123",
|
|
||||||
GroupSubject: "#general",
|
|
||||||
};
|
|
||||||
const groupResolution: GroupKeyResolution = {
|
|
||||||
channel: "slack",
|
|
||||||
id: "C123",
|
|
||||||
chatType: "group",
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import type { MsgContext } from "../templating.js";
|
|
||||||
import { finalizeInboundContext } from "./inbound-context.js";
|
|
||||||
|
|
||||||
describe("finalizeInboundContext", () => {
|
|
||||||
it("fills BodyForAgent/BodyForCommands and normalizes newlines", () => {
|
|
||||||
const ctx: MsgContext = {
|
|
||||||
Body: "a\\nb\r\nc",
|
|
||||||
RawBody: "raw\\nline",
|
|
||||||
ChatType: "channel",
|
|
||||||
From: "whatsapp:group:123@g.us",
|
|
||||||
GroupSubject: "Test",
|
|
||||||
};
|
|
||||||
|
|
||||||
const out = finalizeInboundContext(ctx);
|
|
||||||
expect(out.Body).toBe("a\nb\nc");
|
|
||||||
expect(out.RawBody).toBe("raw\nline");
|
|
||||||
expect(out.BodyForAgent).toBe("a\nb\nc");
|
|
||||||
expect(out.BodyForCommands).toBe("raw\nline");
|
|
||||||
expect(out.CommandAuthorized).toBe(false);
|
|
||||||
expect(out.ChatType).toBe("channel");
|
|
||||||
expect(out.ConversationLabel).toContain("Test");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can force BodyForCommands to follow updated CommandBody", () => {
|
|
||||||
const ctx: MsgContext = {
|
|
||||||
Body: "base",
|
|
||||||
BodyForCommands: "<media:audio>",
|
|
||||||
CommandBody: "say hi",
|
|
||||||
From: "signal:+15550001111",
|
|
||||||
ChatType: "direct",
|
|
||||||
};
|
|
||||||
|
|
||||||
finalizeInboundContext(ctx, { forceBodyForCommands: true });
|
|
||||||
expect(ctx.BodyForCommands).toBe("say hi");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import type { MsgContext } from "../templating.js";
|
|
||||||
import {
|
|
||||||
buildInboundDedupeKey,
|
|
||||||
resetInboundDedupe,
|
|
||||||
shouldSkipDuplicateInbound,
|
|
||||||
} from "./inbound-dedupe.js";
|
|
||||||
|
|
||||||
describe("inbound dedupe", () => {
|
|
||||||
it("builds a stable key when MessageSid is present", () => {
|
|
||||||
const ctx: MsgContext = {
|
|
||||||
Provider: "telegram",
|
|
||||||
OriginatingChannel: "telegram",
|
|
||||||
OriginatingTo: "telegram:123",
|
|
||||||
MessageSid: "42",
|
|
||||||
};
|
|
||||||
expect(buildInboundDedupeKey(ctx)).toBe("telegram|telegram:123|42");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips duplicates with the same key", () => {
|
|
||||||
resetInboundDedupe();
|
|
||||||
const ctx: MsgContext = {
|
|
||||||
Provider: "whatsapp",
|
|
||||||
OriginatingChannel: "whatsapp",
|
|
||||||
OriginatingTo: "whatsapp:+1555",
|
|
||||||
MessageSid: "msg-1",
|
|
||||||
};
|
|
||||||
expect(shouldSkipDuplicateInbound(ctx, { now: 100 })).toBe(false);
|
|
||||||
expect(shouldSkipDuplicateInbound(ctx, { now: 200 })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not dedupe when the peer changes", () => {
|
|
||||||
resetInboundDedupe();
|
|
||||||
const base: MsgContext = {
|
|
||||||
Provider: "whatsapp",
|
|
||||||
OriginatingChannel: "whatsapp",
|
|
||||||
MessageSid: "msg-1",
|
|
||||||
};
|
|
||||||
expect(
|
|
||||||
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+1000" }, { now: 100 }),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+2000" }, { now: 200 }),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not dedupe across session keys", () => {
|
|
||||||
resetInboundDedupe();
|
|
||||||
const base: MsgContext = {
|
|
||||||
Provider: "whatsapp",
|
|
||||||
OriginatingChannel: "whatsapp",
|
|
||||||
OriginatingTo: "whatsapp:+1555",
|
|
||||||
MessageSid: "msg-1",
|
|
||||||
};
|
|
||||||
expect(
|
|
||||||
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 100 }),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:bravo:main" }, { now: 200 }),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 300 }),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import type { MsgContext } from "../templating.js";
|
|
||||||
import { formatInboundBodyWithSenderMeta } from "./inbound-sender-meta.js";
|
|
||||||
|
|
||||||
describe("formatInboundBodyWithSenderMeta", () => {
|
|
||||||
it("does nothing for direct messages", () => {
|
|
||||||
const ctx: MsgContext = { ChatType: "direct", SenderName: "Alice", SenderId: "A1" };
|
|
||||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe("[X] hi");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("appends a sender meta line for non-direct messages", () => {
|
|
||||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
|
||||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe(
|
|
||||||
"[X] hi\n[from: Alice (A1)]",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers SenderE164 in the label when present", () => {
|
|
||||||
const ctx: MsgContext = {
|
|
||||||
ChatType: "group",
|
|
||||||
SenderName: "Bob",
|
|
||||||
SenderId: "bob@s.whatsapp.net",
|
|
||||||
SenderE164: "+222",
|
|
||||||
};
|
|
||||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe(
|
|
||||||
"[X] hi\n[from: Bob (+222)]",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("appends with a real newline even if the body contains literal \\\\n", () => {
|
|
||||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Bob", SenderId: "+222" };
|
|
||||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] one\\n[X] two" })).toBe(
|
|
||||||
"[X] one\\n[X] two\n[from: Bob (+222)]",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not duplicate a sender meta line when one is already present", () => {
|
|
||||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
|
||||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi\n[from: Alice (A1)]" })).toBe(
|
|
||||||
"[X] hi\n[from: Alice (A1)]",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not append when the body already includes a sender prefix", () => {
|
|
||||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
|
||||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "Alice (A1): hi" })).toBe("Alice (A1): hi");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not append when the sender prefix follows an envelope header", () => {
|
|
||||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
|
||||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[Signal Group] Alice (A1): hi" })).toBe(
|
|
||||||
"[Signal Group] Alice (A1): hi",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { normalizeInboundTextNewlines } from "./inbound-text.js";
|
|
||||||
|
|
||||||
describe("normalizeInboundTextNewlines", () => {
|
|
||||||
it("keeps real newlines", () => {
|
|
||||||
expect(normalizeInboundTextNewlines("a\nb")).toBe("a\nb");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes CRLF/CR to LF", () => {
|
|
||||||
expect(normalizeInboundTextNewlines("a\r\nb")).toBe("a\nb");
|
|
||||||
expect(normalizeInboundTextNewlines("a\rb")).toBe("a\nb");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("decodes literal \\\\n to newlines when no real newlines exist", () => {
|
|
||||||
expect(normalizeInboundTextNewlines("a\\nb")).toBe("a\nb");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { buildMentionRegexes, matchesMentionPatterns, normalizeMentionText } from "./mentions.js";
|
|
||||||
|
|
||||||
describe("mention helpers", () => {
|
|
||||||
it("builds regexes and skips invalid patterns", () => {
|
|
||||||
const regexes = buildMentionRegexes({
|
|
||||||
messages: {
|
|
||||||
groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(regexes).toHaveLength(1);
|
|
||||||
expect(regexes[0]?.test("clawd")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes zero-width characters", () => {
|
|
||||||
expect(normalizeMentionText("cl\u200bawd")).toBe("clawd");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches patterns case-insensitively", () => {
|
|
||||||
const regexes = buildMentionRegexes({
|
|
||||||
messages: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } },
|
|
||||||
});
|
|
||||||
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses per-agent mention patterns when configured", () => {
|
|
||||||
const regexes = buildMentionRegexes(
|
|
||||||
{
|
|
||||||
messages: {
|
|
||||||
groupChat: { mentionPatterns: ["\\bglobal\\b"] },
|
|
||||||
},
|
|
||||||
agents: {
|
|
||||||
list: [
|
|
||||||
{
|
|
||||||
id: "work",
|
|
||||||
groupChat: { mentionPatterns: ["\\bworkbot\\b"] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"work",
|
|
||||||
);
|
|
||||||
expect(matchesMentionPatterns("workbot: hi", regexes)).toBe(true);
|
|
||||||
expect(matchesMentionPatterns("global: hi", regexes)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { createReplyReferencePlanner } from "./reply-reference.js";
|
|
||||||
|
|
||||||
describe("createReplyReferencePlanner", () => {
|
|
||||||
it("disables references when mode is off", () => {
|
|
||||||
const planner = createReplyReferencePlanner({
|
|
||||||
replyToMode: "off",
|
|
||||||
startId: "parent",
|
|
||||||
});
|
|
||||||
expect(planner.use()).toBeUndefined();
|
|
||||||
expect(planner.hasReplied()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses startId once when mode is first", () => {
|
|
||||||
const planner = createReplyReferencePlanner({
|
|
||||||
replyToMode: "first",
|
|
||||||
startId: "parent",
|
|
||||||
});
|
|
||||||
expect(planner.use()).toBe("parent");
|
|
||||||
expect(planner.hasReplied()).toBe(true);
|
|
||||||
planner.markSent();
|
|
||||||
expect(planner.use()).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns startId for every call when mode is all", () => {
|
|
||||||
const planner = createReplyReferencePlanner({
|
|
||||||
replyToMode: "all",
|
|
||||||
startId: "parent",
|
|
||||||
});
|
|
||||||
expect(planner.use()).toBe("parent");
|
|
||||||
expect(planner.use()).toBe("parent");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers existing thread id regardless of mode", () => {
|
|
||||||
const planner = createReplyReferencePlanner({
|
|
||||||
replyToMode: "off",
|
|
||||||
existingId: "thread-1",
|
|
||||||
startId: "parent",
|
|
||||||
});
|
|
||||||
expect(planner.use()).toBe("thread-1");
|
|
||||||
expect(planner.hasReplied()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("honors allowReference=false", () => {
|
|
||||||
const planner = createReplyReferencePlanner({
|
|
||||||
replyToMode: "all",
|
|
||||||
startId: "parent",
|
|
||||||
allowReference: false,
|
|
||||||
});
|
|
||||||
expect(planner.use()).toBeUndefined();
|
|
||||||
expect(planner.hasReplied()).toBe(false);
|
|
||||||
planner.markSent();
|
|
||||||
expect(planner.hasReplied()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js";
|
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||||
import { createReplyDispatcher } from "./reply-dispatcher.js";
|
import { createReplyDispatcher } from "./reply-dispatcher.js";
|
||||||
|
import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js";
|
||||||
|
|
||||||
|
const emptyCfg = {} as ClawdbotConfig;
|
||||||
|
|
||||||
describe("createReplyDispatcher", () => {
|
describe("createReplyDispatcher", () => {
|
||||||
it("drops empty payloads and silent tokens without media", async () => {
|
it("drops empty payloads and silent tokens without media", async () => {
|
||||||
@@ -150,3 +155,94 @@ describe("createReplyDispatcher", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveReplyToMode", () => {
|
||||||
|
it("defaults to first for Telegram", () => {
|
||||||
|
expect(resolveReplyToMode(emptyCfg, "telegram")).toBe("first");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to off for Discord and Slack", () => {
|
||||||
|
expect(resolveReplyToMode(emptyCfg, "discord")).toBe("off");
|
||||||
|
expect(resolveReplyToMode(emptyCfg, "slack")).toBe("off");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to all when channel is unknown", () => {
|
||||||
|
expect(resolveReplyToMode(emptyCfg, undefined)).toBe("all");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses configured value when present", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
telegram: { replyToMode: "all" },
|
||||||
|
discord: { replyToMode: "first" },
|
||||||
|
slack: { replyToMode: "all" },
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
expect(resolveReplyToMode(cfg, "telegram")).toBe("all");
|
||||||
|
expect(resolveReplyToMode(cfg, "discord")).toBe("first");
|
||||||
|
expect(resolveReplyToMode(cfg, "slack")).toBe("all");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses chat-type replyToMode overrides for Slack when configured", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
replyToMode: "off",
|
||||||
|
replyToModeByChatType: { direct: "all", group: "first" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
|
||||||
|
expect(resolveReplyToMode(cfg, "slack", null, "group")).toBe("first");
|
||||||
|
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
|
||||||
|
expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to top-level replyToMode when no chat-type override is set", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
replyToMode: "first",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first");
|
||||||
|
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
replyToMode: "off",
|
||||||
|
dm: { replyToMode: "all" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
|
||||||
|
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createReplyToModeFilter", () => {
|
||||||
|
it("drops replyToId when mode is off", () => {
|
||||||
|
const filter = createReplyToModeFilter("off");
|
||||||
|
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps replyToId when mode is off and reply tags are allowed", () => {
|
||||||
|
const filter = createReplyToModeFilter("off", { allowTagsWhenOff: true });
|
||||||
|
expect(filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps replyToId when mode is all", () => {
|
||||||
|
const filter = createReplyToModeFilter("all");
|
||||||
|
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only the first replyToId when mode is first", () => {
|
||||||
|
const filter = createReplyToModeFilter("first");
|
||||||
|
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1");
|
||||||
|
expect(filter({ text: "next", replyToId: "1" }).replyToId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
|
||||||
import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js";
|
|
||||||
|
|
||||||
const emptyCfg = {} as ClawdbotConfig;
|
|
||||||
|
|
||||||
describe("resolveReplyToMode", () => {
|
|
||||||
it("defaults to first for Telegram", () => {
|
|
||||||
expect(resolveReplyToMode(emptyCfg, "telegram")).toBe("first");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults to off for Discord and Slack", () => {
|
|
||||||
expect(resolveReplyToMode(emptyCfg, "discord")).toBe("off");
|
|
||||||
expect(resolveReplyToMode(emptyCfg, "slack")).toBe("off");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults to all when channel is unknown", () => {
|
|
||||||
expect(resolveReplyToMode(emptyCfg, undefined)).toBe("all");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses configured value when present", () => {
|
|
||||||
const cfg = {
|
|
||||||
channels: {
|
|
||||||
telegram: { replyToMode: "all" },
|
|
||||||
discord: { replyToMode: "first" },
|
|
||||||
slack: { replyToMode: "all" },
|
|
||||||
},
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
expect(resolveReplyToMode(cfg, "telegram")).toBe("all");
|
|
||||||
expect(resolveReplyToMode(cfg, "discord")).toBe("first");
|
|
||||||
expect(resolveReplyToMode(cfg, "slack")).toBe("all");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses chat-type replyToMode overrides for Slack when configured", () => {
|
|
||||||
const cfg = {
|
|
||||||
channels: {
|
|
||||||
slack: {
|
|
||||||
replyToMode: "off",
|
|
||||||
replyToModeByChatType: { direct: "all", group: "first" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
|
|
||||||
expect(resolveReplyToMode(cfg, "slack", null, "group")).toBe("first");
|
|
||||||
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
|
|
||||||
expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to top-level replyToMode when no chat-type override is set", () => {
|
|
||||||
const cfg = {
|
|
||||||
channels: {
|
|
||||||
slack: {
|
|
||||||
replyToMode: "first",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first");
|
|
||||||
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => {
|
|
||||||
const cfg = {
|
|
||||||
channels: {
|
|
||||||
slack: {
|
|
||||||
replyToMode: "off",
|
|
||||||
dm: { replyToMode: "all" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
|
|
||||||
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createReplyToModeFilter", () => {
|
|
||||||
it("drops replyToId when mode is off", () => {
|
|
||||||
const filter = createReplyToModeFilter("off");
|
|
||||||
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps replyToId when mode is off and reply tags are allowed", () => {
|
|
||||||
const filter = createReplyToModeFilter("off", { allowTagsWhenOff: true });
|
|
||||||
expect(filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId).toBe("1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps replyToId when mode is all", () => {
|
|
||||||
const filter = createReplyToModeFilter("all");
|
|
||||||
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps only the first replyToId when mode is first", () => {
|
|
||||||
const filter = createReplyToModeFilter("first");
|
|
||||||
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1");
|
|
||||||
expect(filter({ text: "next", replyToId: "1" }).replyToId).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
|
||||||
import { buildModelAliasIndex } from "../../agents/model-selection.js";
|
|
||||||
import { applyResetModelOverride } from "./session-reset-model.js";
|
|
||||||
|
|
||||||
vi.mock("../../agents/model-catalog.js", () => ({
|
|
||||||
loadModelCatalog: vi.fn(async () => [
|
|
||||||
{ provider: "minimax", id: "m2.1", name: "M2.1" },
|
|
||||||
{ provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" },
|
|
||||||
]),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("applyResetModelOverride", () => {
|
|
||||||
it("selects a model hint and strips it from the body", async () => {
|
|
||||||
const cfg = {} as ClawdbotConfig;
|
|
||||||
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
|
|
||||||
const sessionEntry = {
|
|
||||||
sessionId: "s1",
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
};
|
|
||||||
const sessionStore = { "agent:main:dm:1": sessionEntry };
|
|
||||||
const sessionCtx = { BodyStripped: "minimax summarize" };
|
|
||||||
const ctx = { ChatType: "direct" };
|
|
||||||
|
|
||||||
await applyResetModelOverride({
|
|
||||||
cfg,
|
|
||||||
resetTriggered: true,
|
|
||||||
bodyStripped: "minimax summarize",
|
|
||||||
sessionCtx,
|
|
||||||
ctx,
|
|
||||||
sessionEntry,
|
|
||||||
sessionStore,
|
|
||||||
sessionKey: "agent:main:dm:1",
|
|
||||||
defaultProvider: "openai",
|
|
||||||
defaultModel: "gpt-4o-mini",
|
|
||||||
aliasIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(sessionEntry.providerOverride).toBe("minimax");
|
|
||||||
expect(sessionEntry.modelOverride).toBe("m2.1");
|
|
||||||
expect(sessionCtx.BodyStripped).toBe("summarize");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears auth profile overrides when reset applies a model", async () => {
|
|
||||||
const cfg = {} as ClawdbotConfig;
|
|
||||||
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
|
|
||||||
const sessionEntry = {
|
|
||||||
sessionId: "s1",
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
authProfileOverride: "anthropic:default",
|
|
||||||
authProfileOverrideSource: "user",
|
|
||||||
authProfileOverrideCompactionCount: 2,
|
|
||||||
};
|
|
||||||
const sessionStore = { "agent:main:dm:1": sessionEntry };
|
|
||||||
const sessionCtx = { BodyStripped: "minimax summarize" };
|
|
||||||
const ctx = { ChatType: "direct" };
|
|
||||||
|
|
||||||
await applyResetModelOverride({
|
|
||||||
cfg,
|
|
||||||
resetTriggered: true,
|
|
||||||
bodyStripped: "minimax summarize",
|
|
||||||
sessionCtx,
|
|
||||||
ctx,
|
|
||||||
sessionEntry,
|
|
||||||
sessionStore,
|
|
||||||
sessionKey: "agent:main:dm:1",
|
|
||||||
defaultProvider: "openai",
|
|
||||||
defaultModel: "gpt-4o-mini",
|
|
||||||
aliasIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(sessionEntry.authProfileOverride).toBeUndefined();
|
|
||||||
expect(sessionEntry.authProfileOverrideSource).toBeUndefined();
|
|
||||||
expect(sessionEntry.authProfileOverrideCompactionCount).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips when resetTriggered is false", async () => {
|
|
||||||
const cfg = {} as ClawdbotConfig;
|
|
||||||
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
|
|
||||||
const sessionEntry = {
|
|
||||||
sessionId: "s1",
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
};
|
|
||||||
const sessionStore = { "agent:main:dm:1": sessionEntry };
|
|
||||||
const sessionCtx = { BodyStripped: "minimax summarize" };
|
|
||||||
const ctx = { ChatType: "direct" };
|
|
||||||
|
|
||||||
await applyResetModelOverride({
|
|
||||||
cfg,
|
|
||||||
resetTriggered: false,
|
|
||||||
bodyStripped: "minimax summarize",
|
|
||||||
sessionCtx,
|
|
||||||
ctx,
|
|
||||||
sessionEntry,
|
|
||||||
sessionStore,
|
|
||||||
sessionKey: "agent:main:dm:1",
|
|
||||||
defaultProvider: "openai",
|
|
||||||
defaultModel: "gpt-4o-mini",
|
|
||||||
aliasIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(sessionEntry.providerOverride).toBeUndefined();
|
|
||||||
expect(sessionEntry.modelOverride).toBeUndefined();
|
|
||||||
expect(sessionCtx.BodyStripped).toBe("minimax summarize");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,10 +2,21 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { buildModelAliasIndex } from "../../agents/model-selection.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
|
||||||
import { initSessionState } from "./session.js";
|
import { initSessionState } from "./session.js";
|
||||||
|
import { applyResetModelOverride } from "./session-reset-model.js";
|
||||||
|
import { prependSystemEvents } from "./session-updates.js";
|
||||||
|
|
||||||
|
vi.mock("../../agents/model-catalog.js", () => ({
|
||||||
|
loadModelCatalog: vi.fn(async () => [
|
||||||
|
{ provider: "minimax", id: "m2.1", name: "M2.1" },
|
||||||
|
{ provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" },
|
||||||
|
]),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("initSessionState reset triggers in WhatsApp groups", () => {
|
describe("initSessionState reset triggers in WhatsApp groups", () => {
|
||||||
async function createStorePath(prefix: string): Promise<string> {
|
async function createStorePath(prefix: string): Promise<string> {
|
||||||
@@ -54,7 +65,6 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
|
|||||||
allowFrom: ["+41796666864"],
|
allowFrom: ["+41796666864"],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Group message context matching what WhatsApp handler creates
|
|
||||||
const groupMessageCtx = {
|
const groupMessageCtx = {
|
||||||
Body: `[Chat messages since your last reply - for context]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Someone: hello\\n\\n[Current message - respond to this]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Peschiño: /new\\n[from: Peschiño (+41796666864)]`,
|
Body: `[Chat messages since your last reply - for context]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Someone: hello\\n\\n[Current message - respond to this]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Peschiño: /new\\n[from: Peschiño (+41796666864)]`,
|
||||||
RawBody: "/new",
|
RawBody: "/new",
|
||||||
@@ -76,7 +86,6 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
|
|||||||
commandAuthorized: true,
|
commandAuthorized: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// The reset should be detected
|
|
||||||
expect(result.triggerBodyNormalized).toBe("/new");
|
expect(result.triggerBodyNormalized).toBe("/new");
|
||||||
expect(result.isNewSession).toBe(true);
|
expect(result.isNewSession).toBe(true);
|
||||||
expect(result.sessionId).not.toBe(existingSessionId);
|
expect(result.sessionId).not.toBe(existingSessionId);
|
||||||
@@ -99,7 +108,6 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
|
|||||||
allowFrom: ["+41796666864"],
|
allowFrom: ["+41796666864"],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Group message from different sender (not in allowFrom)
|
|
||||||
const groupMessageCtx = {
|
const groupMessageCtx = {
|
||||||
Body: `[Context]\\n[WhatsApp ...] OtherPerson: /new\\n[from: OtherPerson (+1555123456)]`,
|
Body: `[Context]\\n[WhatsApp ...] OtherPerson: /new\\n[from: OtherPerson (+1555123456)]`,
|
||||||
RawBody: "/new",
|
RawBody: "/new",
|
||||||
@@ -111,7 +119,7 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
|
|||||||
Provider: "whatsapp",
|
Provider: "whatsapp",
|
||||||
Surface: "whatsapp",
|
Surface: "whatsapp",
|
||||||
SenderName: "OtherPerson",
|
SenderName: "OtherPerson",
|
||||||
SenderE164: "+1555123456", // Different sender (not authorized)
|
SenderE164: "+1555123456",
|
||||||
SenderId: "1555123456:0@s.whatsapp.net",
|
SenderId: "1555123456:0@s.whatsapp.net",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -121,9 +129,8 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
|
|||||||
commandAuthorized: true,
|
commandAuthorized: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset should NOT be triggered for unauthorized sender - session ID should stay the same
|
|
||||||
expect(result.triggerBodyNormalized).toBe("/new");
|
expect(result.triggerBodyNormalized).toBe("/new");
|
||||||
expect(result.sessionId).toBe(existingSessionId); // Session should NOT change
|
expect(result.sessionId).toBe(existingSessionId);
|
||||||
expect(result.isNewSession).toBe(false);
|
expect(result.isNewSession).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,9 +150,7 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const groupMessageCtx = {
|
const groupMessageCtx = {
|
||||||
// Body is wrapped with context prefixes
|
|
||||||
Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Jake: /new\n[from: Jake (+1222)]`,
|
Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Jake: /new\n[from: Jake (+1222)]`,
|
||||||
// RawBody is clean
|
|
||||||
RawBody: "/new",
|
RawBody: "/new",
|
||||||
CommandBody: "/new",
|
CommandBody: "/new",
|
||||||
From: "120363406150318674@g.us",
|
From: "120363406150318674@g.us",
|
||||||
@@ -251,3 +256,124 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
|
|||||||
expect(result.isNewSession).toBe(false);
|
expect(result.isNewSession).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("applyResetModelOverride", () => {
|
||||||
|
it("selects a model hint and strips it from the body", async () => {
|
||||||
|
const cfg = {} as ClawdbotConfig;
|
||||||
|
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
|
||||||
|
const sessionEntry = {
|
||||||
|
sessionId: "s1",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
const sessionStore = { "agent:main:dm:1": sessionEntry };
|
||||||
|
const sessionCtx = { BodyStripped: "minimax summarize" };
|
||||||
|
const ctx = { ChatType: "direct" };
|
||||||
|
|
||||||
|
await applyResetModelOverride({
|
||||||
|
cfg,
|
||||||
|
resetTriggered: true,
|
||||||
|
bodyStripped: "minimax summarize",
|
||||||
|
sessionCtx,
|
||||||
|
ctx,
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey: "agent:main:dm:1",
|
||||||
|
defaultProvider: "openai",
|
||||||
|
defaultModel: "gpt-4o-mini",
|
||||||
|
aliasIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sessionEntry.providerOverride).toBe("minimax");
|
||||||
|
expect(sessionEntry.modelOverride).toBe("m2.1");
|
||||||
|
expect(sessionCtx.BodyStripped).toBe("summarize");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears auth profile overrides when reset applies a model", async () => {
|
||||||
|
const cfg = {} as ClawdbotConfig;
|
||||||
|
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
|
||||||
|
const sessionEntry = {
|
||||||
|
sessionId: "s1",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
authProfileOverride: "anthropic:default",
|
||||||
|
authProfileOverrideSource: "user",
|
||||||
|
authProfileOverrideCompactionCount: 2,
|
||||||
|
};
|
||||||
|
const sessionStore = { "agent:main:dm:1": sessionEntry };
|
||||||
|
const sessionCtx = { BodyStripped: "minimax summarize" };
|
||||||
|
const ctx = { ChatType: "direct" };
|
||||||
|
|
||||||
|
await applyResetModelOverride({
|
||||||
|
cfg,
|
||||||
|
resetTriggered: true,
|
||||||
|
bodyStripped: "minimax summarize",
|
||||||
|
sessionCtx,
|
||||||
|
ctx,
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey: "agent:main:dm:1",
|
||||||
|
defaultProvider: "openai",
|
||||||
|
defaultModel: "gpt-4o-mini",
|
||||||
|
aliasIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sessionEntry.authProfileOverride).toBeUndefined();
|
||||||
|
expect(sessionEntry.authProfileOverrideSource).toBeUndefined();
|
||||||
|
expect(sessionEntry.authProfileOverrideCompactionCount).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips when resetTriggered is false", async () => {
|
||||||
|
const cfg = {} as ClawdbotConfig;
|
||||||
|
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
|
||||||
|
const sessionEntry = {
|
||||||
|
sessionId: "s1",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
const sessionStore = { "agent:main:dm:1": sessionEntry };
|
||||||
|
const sessionCtx = { BodyStripped: "minimax summarize" };
|
||||||
|
const ctx = { ChatType: "direct" };
|
||||||
|
|
||||||
|
await applyResetModelOverride({
|
||||||
|
cfg,
|
||||||
|
resetTriggered: false,
|
||||||
|
bodyStripped: "minimax summarize",
|
||||||
|
sessionCtx,
|
||||||
|
ctx,
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey: "agent:main:dm:1",
|
||||||
|
defaultProvider: "openai",
|
||||||
|
defaultModel: "gpt-4o-mini",
|
||||||
|
aliasIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sessionEntry.providerOverride).toBeUndefined();
|
||||||
|
expect(sessionEntry.modelOverride).toBeUndefined();
|
||||||
|
expect(sessionCtx.BodyStripped).toBe("minimax summarize");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("prependSystemEvents", () => {
|
||||||
|
it("adds a local timestamp to queued system events by default", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const originalTz = process.env.TZ;
|
||||||
|
process.env.TZ = "America/Los_Angeles";
|
||||||
|
const timestamp = new Date("2026-01-12T20:19:17Z");
|
||||||
|
vi.setSystemTime(timestamp);
|
||||||
|
|
||||||
|
enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" });
|
||||||
|
|
||||||
|
const result = await prependSystemEvents({
|
||||||
|
cfg: {} as ClawdbotConfig,
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
isMainSession: false,
|
||||||
|
isNewSession: false,
|
||||||
|
prefixedBodyBase: "User: hi",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatch(/System: \[2026-01-12 12:19:17 [^\]]+\] Model switched\./);
|
||||||
|
|
||||||
|
resetSystemEventsForTest();
|
||||||
|
process.env.TZ = originalTz;
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
|
||||||
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
|
|
||||||
import { prependSystemEvents } from "./session-updates.js";
|
|
||||||
|
|
||||||
describe("prependSystemEvents", () => {
|
|
||||||
it("adds a local timestamp to queued system events by default", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const originalTz = process.env.TZ;
|
|
||||||
process.env.TZ = "America/Los_Angeles";
|
|
||||||
const timestamp = new Date("2026-01-12T20:19:17Z");
|
|
||||||
vi.setSystemTime(timestamp);
|
|
||||||
|
|
||||||
enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" });
|
|
||||||
|
|
||||||
const result = await prependSystemEvents({
|
|
||||||
cfg: {} as ClawdbotConfig,
|
|
||||||
sessionKey: "agent:main:main",
|
|
||||||
isMainSession: false,
|
|
||||||
isNewSession: false,
|
|
||||||
prefixedBodyBase: "User: hi",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatch(/System: \[2026-01-12 12:19:17 [^\]]+\] Model switched\./);
|
|
||||||
|
|
||||||
resetSystemEventsForTest();
|
|
||||||
process.env.TZ = originalTz;
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
|
||||||
import { initSessionState } from "./session.js";
|
|
||||||
|
|
||||||
describe("initSessionState sender meta", () => {
|
|
||||||
it("injects sender meta into BodyStripped for group chats", async () => {
|
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-"));
|
|
||||||
const storePath = path.join(root, "sessions.json");
|
|
||||||
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
|
||||||
|
|
||||||
const result = await initSessionState({
|
|
||||||
ctx: {
|
|
||||||
Body: "[WhatsApp 123@g.us] ping",
|
|
||||||
ChatType: "group",
|
|
||||||
SenderName: "Bob",
|
|
||||||
SenderE164: "+222",
|
|
||||||
SenderId: "222@s.whatsapp.net",
|
|
||||||
SessionKey: "agent:main:whatsapp:group:123@g.us",
|
|
||||||
},
|
|
||||||
cfg,
|
|
||||||
commandAuthorized: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp 123@g.us] ping\n[from: Bob (+222)]");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not inject sender meta for direct chats", async () => {
|
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-direct-"));
|
|
||||||
const storePath = path.join(root, "sessions.json");
|
|
||||||
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
|
||||||
|
|
||||||
const result = await initSessionState({
|
|
||||||
ctx: {
|
|
||||||
Body: "[WhatsApp +1] ping",
|
|
||||||
ChatType: "direct",
|
|
||||||
SenderName: "Bob",
|
|
||||||
SenderE164: "+222",
|
|
||||||
SessionKey: "agent:main:whatsapp:dm:+222",
|
|
||||||
},
|
|
||||||
cfg,
|
|
||||||
commandAuthorized: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp +1] ping");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { createStreamingDirectiveAccumulator } from "./streaming-directives.js";
|
|
||||||
|
|
||||||
describe("createStreamingDirectiveAccumulator", () => {
|
|
||||||
it("stashes reply_to_current until a renderable chunk arrives", () => {
|
|
||||||
const accumulator = createStreamingDirectiveAccumulator();
|
|
||||||
|
|
||||||
expect(accumulator.consume("[[reply_to_current]]")).toBeNull();
|
|
||||||
|
|
||||||
const result = accumulator.consume("Hello");
|
|
||||||
expect(result?.text).toBe("Hello");
|
|
||||||
expect(result?.replyToCurrent).toBe(true);
|
|
||||||
expect(result?.replyToTag).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles reply tags split across chunks", () => {
|
|
||||||
const accumulator = createStreamingDirectiveAccumulator();
|
|
||||||
|
|
||||||
expect(accumulator.consume("[[reply_to_")).toBeNull();
|
|
||||||
|
|
||||||
const result = accumulator.consume("current]] Yo");
|
|
||||||
expect(result?.text).toBe("Yo");
|
|
||||||
expect(result?.replyToCurrent).toBe(true);
|
|
||||||
expect(result?.replyToTag).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("propagates explicit reply ids across chunks", () => {
|
|
||||||
const accumulator = createStreamingDirectiveAccumulator();
|
|
||||||
|
|
||||||
expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull();
|
|
||||||
|
|
||||||
const result = accumulator.consume("Hi");
|
|
||||||
expect(result?.text).toBe("Hi");
|
|
||||||
expect(result?.replyToId).toBe("abc-123");
|
|
||||||
expect(result?.replyToTag).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { createMockTypingController } from "./test-helpers.js";
|
|
||||||
import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js";
|
|
||||||
|
|
||||||
describe("resolveTypingMode", () => {
|
|
||||||
it("defaults to instant for direct chats", () => {
|
|
||||||
expect(
|
|
||||||
resolveTypingMode({
|
|
||||||
configured: undefined,
|
|
||||||
isGroupChat: false,
|
|
||||||
wasMentioned: false,
|
|
||||||
isHeartbeat: false,
|
|
||||||
}),
|
|
||||||
).toBe("instant");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults to message for group chats without mentions", () => {
|
|
||||||
expect(
|
|
||||||
resolveTypingMode({
|
|
||||||
configured: undefined,
|
|
||||||
isGroupChat: true,
|
|
||||||
wasMentioned: false,
|
|
||||||
isHeartbeat: false,
|
|
||||||
}),
|
|
||||||
).toBe("message");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults to instant for mentioned group chats", () => {
|
|
||||||
expect(
|
|
||||||
resolveTypingMode({
|
|
||||||
configured: undefined,
|
|
||||||
isGroupChat: true,
|
|
||||||
wasMentioned: true,
|
|
||||||
isHeartbeat: false,
|
|
||||||
}),
|
|
||||||
).toBe("instant");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("honors configured mode across contexts", () => {
|
|
||||||
expect(
|
|
||||||
resolveTypingMode({
|
|
||||||
configured: "thinking",
|
|
||||||
isGroupChat: false,
|
|
||||||
wasMentioned: false,
|
|
||||||
isHeartbeat: false,
|
|
||||||
}),
|
|
||||||
).toBe("thinking");
|
|
||||||
expect(
|
|
||||||
resolveTypingMode({
|
|
||||||
configured: "message",
|
|
||||||
isGroupChat: true,
|
|
||||||
wasMentioned: true,
|
|
||||||
isHeartbeat: false,
|
|
||||||
}),
|
|
||||||
).toBe("message");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("forces never for heartbeat runs", () => {
|
|
||||||
expect(
|
|
||||||
resolveTypingMode({
|
|
||||||
configured: "instant",
|
|
||||||
isGroupChat: false,
|
|
||||||
wasMentioned: false,
|
|
||||||
isHeartbeat: true,
|
|
||||||
}),
|
|
||||||
).toBe("never");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createTypingSignaler", () => {
|
|
||||||
it("signals immediately for instant mode", async () => {
|
|
||||||
const typing = createMockTypingController();
|
|
||||||
const signaler = createTypingSignaler({
|
|
||||||
typing,
|
|
||||||
mode: "instant",
|
|
||||||
isHeartbeat: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await signaler.signalRunStart();
|
|
||||||
|
|
||||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("signals on text for message mode", async () => {
|
|
||||||
const typing = createMockTypingController();
|
|
||||||
const signaler = createTypingSignaler({
|
|
||||||
typing,
|
|
||||||
mode: "message",
|
|
||||||
isHeartbeat: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await signaler.signalTextDelta("hello");
|
|
||||||
|
|
||||||
expect(typing.startTypingOnText).toHaveBeenCalledWith("hello");
|
|
||||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("signals on message start for message mode", async () => {
|
|
||||||
const typing = createMockTypingController();
|
|
||||||
const signaler = createTypingSignaler({
|
|
||||||
typing,
|
|
||||||
mode: "message",
|
|
||||||
isHeartbeat: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await signaler.signalMessageStart();
|
|
||||||
|
|
||||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
|
||||||
await signaler.signalTextDelta("hello");
|
|
||||||
expect(typing.startTypingOnText).toHaveBeenCalledWith("hello");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("signals on reasoning for thinking mode", async () => {
|
|
||||||
const typing = createMockTypingController();
|
|
||||||
const signaler = createTypingSignaler({
|
|
||||||
typing,
|
|
||||||
mode: "thinking",
|
|
||||||
isHeartbeat: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await signaler.signalReasoningDelta();
|
|
||||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
|
||||||
await signaler.signalTextDelta("hi");
|
|
||||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refreshes ttl on text for thinking mode", async () => {
|
|
||||||
const typing = createMockTypingController();
|
|
||||||
const signaler = createTypingSignaler({
|
|
||||||
typing,
|
|
||||||
mode: "thinking",
|
|
||||||
isHeartbeat: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await signaler.signalTextDelta("hi");
|
|
||||||
|
|
||||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
|
||||||
expect(typing.refreshTypingTtl).toHaveBeenCalled();
|
|
||||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts typing on tool start before text", async () => {
|
|
||||||
const typing = createMockTypingController();
|
|
||||||
const signaler = createTypingSignaler({
|
|
||||||
typing,
|
|
||||||
mode: "message",
|
|
||||||
isHeartbeat: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await signaler.signalToolStart();
|
|
||||||
|
|
||||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
|
||||||
expect(typing.refreshTypingTtl).toHaveBeenCalled();
|
|
||||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refreshes ttl on tool start when active after text", async () => {
|
|
||||||
const typing = createMockTypingController({
|
|
||||||
isActive: vi.fn(() => true),
|
|
||||||
});
|
|
||||||
const signaler = createTypingSignaler({
|
|
||||||
typing,
|
|
||||||
mode: "message",
|
|
||||||
isHeartbeat: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await signaler.signalTextDelta("hello");
|
|
||||||
typing.startTypingLoop.mockClear();
|
|
||||||
typing.startTypingOnText.mockClear();
|
|
||||||
typing.refreshTypingTtl.mockClear();
|
|
||||||
await signaler.signalToolStart();
|
|
||||||
|
|
||||||
expect(typing.refreshTypingTtl).toHaveBeenCalled();
|
|
||||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("suppresses typing when disabled", async () => {
|
|
||||||
const typing = createMockTypingController();
|
|
||||||
const signaler = createTypingSignaler({
|
|
||||||
typing,
|
|
||||||
mode: "instant",
|
|
||||||
isHeartbeat: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await signaler.signalRunStart();
|
|
||||||
await signaler.signalTextDelta("hi");
|
|
||||||
await signaler.signalReasoningDelta();
|
|
||||||
|
|
||||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
|
||||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createMockTypingController } from "./test-helpers.js";
|
||||||
|
import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js";
|
||||||
import { createTypingController } from "./typing.js";
|
import { createTypingController } from "./typing.js";
|
||||||
|
|
||||||
describe("typing controller", () => {
|
describe("typing controller", () => {
|
||||||
@@ -91,3 +93,192 @@ describe("typing controller", () => {
|
|||||||
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveTypingMode", () => {
|
||||||
|
it("defaults to instant for direct chats", () => {
|
||||||
|
expect(
|
||||||
|
resolveTypingMode({
|
||||||
|
configured: undefined,
|
||||||
|
isGroupChat: false,
|
||||||
|
wasMentioned: false,
|
||||||
|
isHeartbeat: false,
|
||||||
|
}),
|
||||||
|
).toBe("instant");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to message for group chats without mentions", () => {
|
||||||
|
expect(
|
||||||
|
resolveTypingMode({
|
||||||
|
configured: undefined,
|
||||||
|
isGroupChat: true,
|
||||||
|
wasMentioned: false,
|
||||||
|
isHeartbeat: false,
|
||||||
|
}),
|
||||||
|
).toBe("message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to instant for mentioned group chats", () => {
|
||||||
|
expect(
|
||||||
|
resolveTypingMode({
|
||||||
|
configured: undefined,
|
||||||
|
isGroupChat: true,
|
||||||
|
wasMentioned: true,
|
||||||
|
isHeartbeat: false,
|
||||||
|
}),
|
||||||
|
).toBe("instant");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors configured mode across contexts", () => {
|
||||||
|
expect(
|
||||||
|
resolveTypingMode({
|
||||||
|
configured: "thinking",
|
||||||
|
isGroupChat: false,
|
||||||
|
wasMentioned: false,
|
||||||
|
isHeartbeat: false,
|
||||||
|
}),
|
||||||
|
).toBe("thinking");
|
||||||
|
expect(
|
||||||
|
resolveTypingMode({
|
||||||
|
configured: "message",
|
||||||
|
isGroupChat: true,
|
||||||
|
wasMentioned: true,
|
||||||
|
isHeartbeat: false,
|
||||||
|
}),
|
||||||
|
).toBe("message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forces never for heartbeat runs", () => {
|
||||||
|
expect(
|
||||||
|
resolveTypingMode({
|
||||||
|
configured: "instant",
|
||||||
|
isGroupChat: false,
|
||||||
|
wasMentioned: false,
|
||||||
|
isHeartbeat: true,
|
||||||
|
}),
|
||||||
|
).toBe("never");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createTypingSignaler", () => {
|
||||||
|
it("signals immediately for instant mode", async () => {
|
||||||
|
const typing = createMockTypingController();
|
||||||
|
const signaler = createTypingSignaler({
|
||||||
|
typing,
|
||||||
|
mode: "instant",
|
||||||
|
isHeartbeat: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await signaler.signalRunStart();
|
||||||
|
|
||||||
|
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signals on text for message mode", async () => {
|
||||||
|
const typing = createMockTypingController();
|
||||||
|
const signaler = createTypingSignaler({
|
||||||
|
typing,
|
||||||
|
mode: "message",
|
||||||
|
isHeartbeat: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await signaler.signalTextDelta("hello");
|
||||||
|
|
||||||
|
expect(typing.startTypingOnText).toHaveBeenCalledWith("hello");
|
||||||
|
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signals on message start for message mode", async () => {
|
||||||
|
const typing = createMockTypingController();
|
||||||
|
const signaler = createTypingSignaler({
|
||||||
|
typing,
|
||||||
|
mode: "message",
|
||||||
|
isHeartbeat: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await signaler.signalMessageStart();
|
||||||
|
|
||||||
|
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||||
|
await signaler.signalTextDelta("hello");
|
||||||
|
expect(typing.startTypingOnText).toHaveBeenCalledWith("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signals on reasoning for thinking mode", async () => {
|
||||||
|
const typing = createMockTypingController();
|
||||||
|
const signaler = createTypingSignaler({
|
||||||
|
typing,
|
||||||
|
mode: "thinking",
|
||||||
|
isHeartbeat: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await signaler.signalReasoningDelta();
|
||||||
|
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||||
|
await signaler.signalTextDelta("hi");
|
||||||
|
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes ttl on text for thinking mode", async () => {
|
||||||
|
const typing = createMockTypingController();
|
||||||
|
const signaler = createTypingSignaler({
|
||||||
|
typing,
|
||||||
|
mode: "thinking",
|
||||||
|
isHeartbeat: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await signaler.signalTextDelta("hi");
|
||||||
|
|
||||||
|
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||||
|
expect(typing.refreshTypingTtl).toHaveBeenCalled();
|
||||||
|
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts typing on tool start before text", async () => {
|
||||||
|
const typing = createMockTypingController();
|
||||||
|
const signaler = createTypingSignaler({
|
||||||
|
typing,
|
||||||
|
mode: "message",
|
||||||
|
isHeartbeat: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await signaler.signalToolStart();
|
||||||
|
|
||||||
|
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||||
|
expect(typing.refreshTypingTtl).toHaveBeenCalled();
|
||||||
|
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes ttl on tool start when active after text", async () => {
|
||||||
|
const typing = createMockTypingController({
|
||||||
|
isActive: vi.fn(() => true),
|
||||||
|
});
|
||||||
|
const signaler = createTypingSignaler({
|
||||||
|
typing,
|
||||||
|
mode: "message",
|
||||||
|
isHeartbeat: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await signaler.signalTextDelta("hello");
|
||||||
|
typing.startTypingLoop.mockClear();
|
||||||
|
typing.startTypingOnText.mockClear();
|
||||||
|
typing.refreshTypingTtl.mockClear();
|
||||||
|
await signaler.signalToolStart();
|
||||||
|
|
||||||
|
expect(typing.refreshTypingTtl).toHaveBeenCalled();
|
||||||
|
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses typing when disabled", async () => {
|
||||||
|
const typing = createMockTypingController();
|
||||||
|
const signaler = createTypingSignaler({
|
||||||
|
typing,
|
||||||
|
mode: "instant",
|
||||||
|
isHeartbeat: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await signaler.signalRunStart();
|
||||||
|
await signaler.signalTextDelta("hi");
|
||||||
|
await signaler.signalReasoningDelta();
|
||||||
|
|
||||||
|
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||||
|
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { applyTemplate, type TemplateContext } from "./templating.js";
|
|
||||||
|
|
||||||
describe("applyTemplate", () => {
|
|
||||||
it("renders primitive values", () => {
|
|
||||||
const ctx = { MessageSid: "sid", IsNewSession: "no" } as TemplateContext;
|
|
||||||
const overrides = ctx as Record<string, unknown>;
|
|
||||||
overrides.MessageSid = 42;
|
|
||||||
overrides.IsNewSession = true;
|
|
||||||
|
|
||||||
expect(applyTemplate("sid={{MessageSid}} new={{IsNewSession}}", ctx)).toBe("sid=42 new=true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders arrays of primitives", () => {
|
|
||||||
const ctx = { MediaPaths: ["a"] } as TemplateContext;
|
|
||||||
(ctx as Record<string, unknown>).MediaPaths = ["a", 2, true, null, { ok: false }];
|
|
||||||
|
|
||||||
expect(applyTemplate("paths={{MediaPaths}}", ctx)).toBe("paths=a,2,true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops object values", () => {
|
|
||||||
const ctx: TemplateContext = { CommandArgs: { raw: "go" } };
|
|
||||||
|
|
||||||
expect(applyTemplate("args={{CommandArgs}}", ctx)).toBe("args=");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders missing placeholders as empty", () => {
|
|
||||||
const ctx: TemplateContext = {};
|
|
||||||
|
|
||||||
expect(applyTemplate("missing={{Missing}}", ctx)).toBe("missing=");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user