feat: add ack reaction defaults

This commit is contained in:
Peter Steinberger
2026-01-06 03:28:35 +00:00
parent 58186aa56e
commit 1a4f7d3388
16 changed files with 318 additions and 25 deletions

View File

@@ -87,6 +87,57 @@ describe("config identity defaults", () => {
});
});
it("defaults ackReaction to identity emoji", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" },
messages: {},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.ackReaction).toBe("🦥");
expect(cfg.messages?.ackReactionScope).toBe("group-mentions");
});
});
it("defaults ackReaction to 👀 when identity is missing", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
messages: {},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.ackReaction).toBe("👀");
expect(cfg.messages?.ackReactionScope).toBe("group-mentions");
});
});
it("does not override explicit values", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");

View File

@@ -54,6 +54,32 @@ export function applyIdentityDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
return mutated ? next : cfg;
}
export function applyMessageDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
const messages = cfg.messages;
const hasAckReaction = messages?.ackReaction !== undefined;
const hasAckScope = messages?.ackReactionScope !== undefined;
if (hasAckReaction && hasAckScope) return cfg;
const fallbackEmoji = cfg.identity?.emoji?.trim() || "👀";
const nextMessages = { ...(messages ?? {}) };
let mutated = false;
if (!hasAckReaction) {
nextMessages.ackReaction = fallbackEmoji;
mutated = true;
}
if (!hasAckScope) {
nextMessages.ackReactionScope = "group-mentions";
mutated = true;
}
if (!mutated) return cfg;
return {
...cfg,
messages: nextMessages,
};
}
export function applySessionDefaults(
cfg: ClawdbotConfig,
options: SessionDefaultsOptions = {},

View File

@@ -11,6 +11,7 @@ import {
import {
applyIdentityDefaults,
applyLoggingDefaults,
applyMessageDefaults,
applyModelDefaults,
applySessionDefaults,
applyTalkApiKey,
@@ -117,7 +118,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const cfg = applyModelDefaults(
applySessionDefaults(
applyLoggingDefaults(
applyIdentityDefaults(validated.data as ClawdbotConfig),
applyMessageDefaults(
applyIdentityDefaults(validated.data as ClawdbotConfig),
),
),
),
);
@@ -148,7 +151,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const exists = deps.fs.existsSync(configPath);
if (!exists) {
const config = applyTalkApiKey(
applyModelDefaults(applySessionDefaults({})),
applyModelDefaults(applySessionDefaults(applyMessageDefaults({}))),
);
const legacyIssues: LegacyConfigIssue[] = [];
return {
@@ -205,7 +208,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
valid: true,
config: applyTalkApiKey(
applyModelDefaults(
applySessionDefaults(applyLoggingDefaults(validated.config)),
applySessionDefaults(
applyLoggingDefaults(applyMessageDefaults(validated.config)),
),
),
),
issues: [],

View File

@@ -97,6 +97,8 @@ const FIELD_LABELS: Record<string, string> = {
"ui.seamColor": "Accent Color",
"browser.controlUrl": "Browser Control URL",
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
"messages.ackReaction": "Ack Reaction Emoji",
"messages.ackReactionScope": "Ack Reaction Scope",
"talk.apiKey": "Talk API Key",
"telegram.botToken": "Telegram Bot Token",
"discord.token": "Discord Bot Token",
@@ -131,6 +133,10 @@ const FIELD_HELP: Record<string, string> = {
"Ordered fallback image models (provider/model).",
"session.agentToAgent.maxPingPongTurns":
"Max reply-back turns between requester and target (05).",
"messages.ackReaction":
"Emoji reaction used to acknowledge inbound messages (empty disables).",
"messages.ackReactionScope":
'When to send ack reactions ("group-mentions", "group-all", "direct", "all").',
};
const FIELD_PLACEHOLDERS: Record<string, string> = {

View File

@@ -449,6 +449,10 @@ export type RoutingConfig = {
export type MessagesConfig = {
messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdbot]" if no allowFrom, else "")
responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞")
/** Emoji reaction used to acknowledge inbound messages (empty disables). */
ackReaction?: string;
/** When to send ack reactions. Default: "group-mentions". */
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all";
};
export type BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback";

View File

@@ -150,6 +150,10 @@ const MessagesSchema = z
.object({
messagePrefix: z.string().optional(),
responsePrefix: z.string().optional(),
ackReaction: z.string().optional(),
ackReactionScope: z
.enum(["group-mentions", "group-all", "direct", "all"])
.optional(),
})
.optional();

View File

@@ -146,6 +146,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
const textLimit = resolveTextChunkLimit(cfg, "discord");
const mentionRegexes = buildMentionRegexes(cfg);
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const historyLimit = Math.max(
0,
opts.historyLimit ?? cfg.discord?.historyLimit ?? 20,
@@ -410,6 +412,27 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
logVerbose(`discord: drop message ${message.id} (empty content)`);
return;
}
const shouldAckReaction = () => {
if (!ackReaction) return false;
if (ackReactionScope === "all") return true;
if (ackReactionScope === "direct") return isDirectMessage;
const isGroupChat = isGuildMessage || isGroupDm;
if (ackReactionScope === "group-all") return isGroupChat;
if (ackReactionScope === "group-mentions") {
if (!isGuildMessage) return false;
if (!resolvedRequireMention) return false;
if (!canDetectMention) return false;
return wasMentioned || shouldBypassMention;
}
return false;
};
if (shouldAckReaction()) {
message.react(ackReaction).catch((err) => {
logVerbose(
`discord react failed for channel ${message.channelId}: ${String(err)}`,
);
});
}
const fromLabel = isDirectMessage
? buildDirectLabel(message)

View File

@@ -5,6 +5,7 @@ import { monitorSlackProvider } from "./monitor.js";
const sendMock = vi.fn();
const replyMock = vi.fn();
const updateLastRouteMock = vi.fn();
const reactMock = vi.fn();
let config: Record<string, unknown> = {};
const getSlackHandlers = () =>
(
@@ -12,6 +13,8 @@ const getSlackHandlers = () =>
__slackHandlers?: Map<string, (args: unknown) => Promise<void>>;
}
).__slackHandlers;
const getSlackClient = () =>
(globalThis as { __slackClient?: Record<string, unknown> }).__slackClient;
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
@@ -39,20 +42,25 @@ vi.mock("@slack/bolt", () => {
const handlers = new Map<string, (args: unknown) => Promise<void>>();
(globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers =
handlers;
const client = {
auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) },
conversations: {
info: vi.fn().mockResolvedValue({
channel: { name: "dm", is_im: true },
}),
},
users: {
info: vi.fn().mockResolvedValue({
user: { profile: { display_name: "Ada" } },
}),
},
reactions: {
add: (...args: unknown[]) => reactMock(...args),
},
};
(globalThis as { __slackClient?: typeof client }).__slackClient = client;
class App {
client = {
auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) },
conversations: {
info: vi.fn().mockResolvedValue({
channel: { name: "dm", is_im: true },
}),
},
users: {
info: vi.fn().mockResolvedValue({
user: { profile: { display_name: "Ada" } },
}),
},
};
client = client;
event(name: string, handler: (args: unknown) => Promise<void>) {
handlers.set(name, handler);
}
@@ -76,13 +84,18 @@ async function waitForEvent(name: string) {
beforeEach(() => {
config = {
messages: { responsePrefix: "PFX" },
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
ackReactionScope: "group-mentions",
},
slack: { dm: { enabled: true }, groupDm: { enabled: false } },
routing: { allowFrom: [] },
};
sendMock.mockReset().mockResolvedValue(undefined);
replyMock.mockReset();
updateLastRouteMock.mockReset();
reactMock.mockReset();
});
describe("monitorSlackProvider tool results", () => {
@@ -201,4 +214,48 @@ describe("monitorSlackProvider tool results", () => {
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "456" });
});
it("reacts to mention-gated room messages when ackReaction is enabled", async () => {
replyMock.mockResolvedValue(undefined);
const client = getSlackClient();
if (!client) throw new Error("Slack client not registered");
const conversations = client.conversations as {
info: ReturnType<typeof vi.fn>;
};
conversations.info.mockResolvedValueOnce({
channel: { name: "general", is_channel: true },
});
const controller = new AbortController();
const run = monitorSlackProvider({
botToken: "bot-token",
appToken: "app-token",
abortSignal: controller.signal,
});
await waitForEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
await handler({
event: {
type: "message",
user: "U1",
text: "<@bot-user> hello",
ts: "456",
channel: "C1",
channel_type: "channel",
},
});
await flush();
controller.abort();
await run;
expect(reactMock).toHaveBeenCalledWith({
channel: "C1",
timestamp: "456",
name: "👀",
});
});
});

View File

@@ -30,6 +30,7 @@ import { getChildLogger } from "../logging.js";
import { detectMime } from "../media/mime.js";
import { saveMediaBuffer } from "../media/store.js";
import type { RuntimeEnv } from "../runtime.js";
import { reactSlackMessage } from "./actions.js";
import { sendMessageSlack } from "./send.js";
import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
@@ -384,6 +385,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
);
const textLimit = resolveTextChunkLimit(cfg, "slack");
const mentionRegexes = buildMentionRegexes(cfg);
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const mediaMaxBytes =
(opts.mediaMaxMb ?? cfg.slack?.mediaMaxMb ?? 20) * 1024 * 1024;
@@ -628,6 +631,30 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
});
const rawBody = (message.text ?? "").trim() || media?.placeholder || "";
if (!rawBody) return;
const shouldAckReaction = () => {
if (!ackReaction) return false;
if (ackReactionScope === "all") return true;
if (ackReactionScope === "direct") return isDirectMessage;
const isGroupChat = isRoom || isGroupDm;
if (ackReactionScope === "group-all") return isGroupChat;
if (ackReactionScope === "group-mentions") {
if (!isRoom) return false;
if (!channelConfig?.requireMention) return false;
if (!canDetectMention) return false;
return wasMentioned || shouldBypassMention;
}
return false;
};
if (shouldAckReaction() && message.ts) {
reactSlackMessage(message.channel, message.ts, ackReaction, {
token: botToken,
client: app.client,
}).catch((err) => {
logVerbose(
`slack react failed for channel ${message.channel}: ${String(err)}`,
);
});
}
const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;

View File

@@ -25,12 +25,14 @@ const useSpy = vi.fn();
const onSpy = vi.fn();
const stopSpy = vi.fn();
const sendChatActionSpy = vi.fn();
const setMessageReactionSpy = vi.fn(async () => undefined);
const sendMessageSpy = vi.fn(async () => ({ message_id: 77 }));
const sendAnimationSpy = vi.fn(async () => ({ message_id: 78 }));
const sendPhotoSpy = vi.fn(async () => ({ message_id: 79 }));
type ApiStub = {
config: { use: (arg: unknown) => void };
sendChatAction: typeof sendChatActionSpy;
setMessageReaction: typeof setMessageReactionSpy;
sendMessage: typeof sendMessageSpy;
sendAnimation: typeof sendAnimationSpy;
sendPhoto: typeof sendPhotoSpy;
@@ -38,6 +40,7 @@ type ApiStub = {
const apiStub: ApiStub = {
config: { use: useSpy },
sendChatAction: sendChatActionSpy,
setMessageReaction: setMessageReactionSpy,
sendMessage: sendMessageSpy,
sendAnimation: sendAnimationSpy,
sendPhoto: sendPhotoSpy,
@@ -74,6 +77,7 @@ describe("createTelegramBot", () => {
loadWebMedia.mockReset();
sendAnimationSpy.mockReset();
sendPhotoSpy.mockReset();
setMessageReactionSpy.mockReset();
});
it("installs grammY throttler", () => {
@@ -178,6 +182,42 @@ describe("createTelegramBot", () => {
expect(payload.WasMentioned).toBe(true);
});
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
onSpy.mockReset();
setMessageReactionSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<
typeof vi.fn
>;
replySpy.mockReset();
loadConfig.mockReturnValue({
messages: { ackReaction: "👀", ackReactionScope: "group-mentions" },
routing: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
telegram: { groups: { "*": { requireMention: true } } },
});
createTelegramBot({ token: "tok" });
const handler = onSpy.mock.calls[0][1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "group", title: "Test Group" },
text: "bert hello",
date: 1736380800,
message_id: 123,
from: { id: 9, first_name: "Ada" },
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(setMessageReactionSpy).toHaveBeenCalledWith(7, 123, [
{ type: "emoji", emoji: "👀" },
]);
});
it("skips group messages when requireMention is enabled and no mention matches", async () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<

View File

@@ -73,6 +73,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const textLimit = resolveTextChunkLimit(cfg, "telegram");
const allowFrom = opts.allowFrom ?? cfg.telegram?.allowFrom;
const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "off";
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const mediaMaxBytes =
(opts.mediaMaxMb ?? cfg.telegram?.mediaMaxMb ?? 5) * 1024 * 1024;
const logger = getChildLogger({ module: "telegram-auto-reply" });
@@ -181,11 +183,6 @@ export function createTelegramBot(opts: TelegramBotOptions) {
}
}
// React to acknowledge message receipt
ctx.react("✍️").catch((err) => {
logVerbose(`telegram react failed for chat ${chatId}: ${String(err)}`);
});
const media = await resolveMedia(
ctx,
mediaMaxBytes,
@@ -200,6 +197,39 @@ export function createTelegramBot(opts: TelegramBotOptions) {
""
).trim();
if (!rawBody) return;
const shouldAckReaction = () => {
if (!ackReaction) return false;
if (ackReactionScope === "all") return true;
if (ackReactionScope === "direct") return !isGroup;
if (ackReactionScope === "group-all") return isGroup;
if (ackReactionScope === "group-mentions") {
if (!isGroup) return false;
if (!resolveGroupRequireMention(chatId)) return false;
if (!canDetectMention) return false;
return wasMentioned || shouldBypassMention;
}
return false;
};
if (shouldAckReaction() && msg.message_id) {
const api = bot.api as unknown as {
setMessageReaction?: (
chatId: number | string,
messageId: number,
reactions: Array<{ type: "emoji"; emoji: string }>,
) => Promise<void>;
};
if (typeof api.setMessageReaction === "function") {
api
.setMessageReaction(chatId, msg.message_id, [
{ type: "emoji", emoji: ackReaction },
])
.catch((err) => {
logVerbose(
`telegram react failed for chat ${chatId}: ${String(err)}`,
);
});
}
}
const replySuffix = replyTarget
? `\n\n[Replying to ${replyTarget.sender}${replyTarget.id ? ` id:${replyTarget.id}` : ""}]\n${replyTarget.body}\n[/Replying]`
: "";