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

@@ -12,6 +12,7 @@
### Fixes
- Typing indicators: stop typing once the reply dispatcher drains to prevent stuck typing across Discord/Telegram/WhatsApp.
- Auto-reply: add configurable ack reactions for inbound messages (default 👀 or `identity.emoji`) with scope controls. Thanks @obviyus for PR #178.
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
- Onboarding: when OpenAI Codex OAuth is used, default to `openai-codex/gpt-5.2` and warn if the selected model lacks auth.
- CLI: auto-migrate legacy config entries on command start (same behavior as gateway startup).

View File

@@ -131,7 +131,7 @@ rotation order used for failover.
Optional agent identity used for defaults and UX. This is written by the macOS onboarding assistant.
If set, CLAWDBOT derives defaults (only when you havent set them explicitly):
- `messages.responsePrefix` from `identity.emoji`
- `messages.ackReaction` from `identity.emoji` (falls back to 👀)
- `routing.groupChat.mentionPatterns` from `identity.name` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp)
```json5
@@ -477,13 +477,15 @@ message envelopes). If unset, Clawdbot uses the host timezone at runtime.
### `messages`
Controls inbound/outbound prefixes.
Controls inbound/outbound prefixes and optional ack reactions.
```json5
{
messages: {
messagePrefix: "[clawdbot]",
responsePrefix: "🦞"
responsePrefix: "🦞",
ackReaction: "👀",
ackReactionScope: "group-mentions"
}
}
```
@@ -491,6 +493,16 @@ Controls inbound/outbound prefixes.
`responsePrefix` is applied to **all outbound replies** (tool summaries, block
streaming, final replies) across providers unless already present.
`ackReaction` sends a best-effort emoji reaction to acknowledge inbound messages
on providers that support reactions (Slack/Discord/Telegram). Defaults to the
configured `identity.emoji` when set, otherwise `"👀"`. Set it to `""` to disable.
`ackReactionScope` controls when reactions fire:
- `group-mentions` (default): only when a group/room requires mentions **and** the bot was mentioned
- `group-all`: all group/room messages
- `direct`: direct messages only
- `all`: all messages
### `talk`
Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` when unset.

View File

@@ -203,6 +203,9 @@ Notes:
}
```
Ack reactions are controlled globally via `messages.ackReaction` +
`messages.ackReactionScope`.
- `dm.enabled`: set `false` to ignore all DMs (default `true`).
- `dm.allowFrom`: DM allowlist (user ids or names). Omit or set to `["*"]` to allow any DM sender.
- `dm.groupEnabled`: enable group DMs (default `false`).

View File

@@ -180,6 +180,9 @@ Tokens can also be supplied via env vars:
- `SLACK_BOT_TOKEN`
- `SLACK_APP_TOKEN`
Ack reactions are controlled globally via `messages.ackReaction` +
`messages.ackReactionScope`.
## Sessions + routing
- DMs share the `main` session (like WhatsApp/Telegram).
- Channels map to `slack:channel:<channelId>` sessions.

View File

@@ -38,6 +38,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup
- Inbound normalization: maps Bot API updates to `MsgContext` with `Surface: "telegram"`, `ChatType: direct|group`, `SenderName`, `MediaPath`/`MediaType` when attachments arrive, `Timestamp`, and reply-to metadata (`ReplyToId`, `ReplyToBody`, `ReplyToSender`) when the user replies; reply context is appended to `Body` as a `[Replying to ...]` block (includes `id:` when available); groups require @bot mention or a `routing.groupChat.mentionPatterns` match by default (override per chat in config).
- Outbound: text and media (photo/video/audio/document) with optional caption; chunked to limits. Typing cue sent best-effort.
- Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.groups` (group allowlist + mention defaults), `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.replyToMode`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported.
- Ack reactions are controlled globally via `messages.ackReaction` + `messages.ackReactionScope`.
- Mention gating precedence (most specific wins): `telegram.groups.<chatId>.requireMention``telegram.groups."*".requireMention` → default `true`.
Example config:

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]`
: "";