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 ### Fixes
- Typing indicators: stop typing once the reply dispatcher drains to prevent stuck typing across Discord/Telegram/WhatsApp. - 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: 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. - 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). - 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. 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): 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) - `routing.groupChat.mentionPatterns` from `identity.name` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp)
```json5 ```json5
@@ -477,13 +477,15 @@ message envelopes). If unset, Clawdbot uses the host timezone at runtime.
### `messages` ### `messages`
Controls inbound/outbound prefixes. Controls inbound/outbound prefixes and optional ack reactions.
```json5 ```json5
{ {
messages: { messages: {
messagePrefix: "[clawdbot]", 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 `responsePrefix` is applied to **all outbound replies** (tool summaries, block
streaming, final replies) across providers unless already present. 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` ### `talk`
Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` when unset. 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.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.allowFrom`: DM allowlist (user ids or names). Omit or set to `["*"]` to allow any DM sender.
- `dm.groupEnabled`: enable group DMs (default `false`). - `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_BOT_TOKEN`
- `SLACK_APP_TOKEN` - `SLACK_APP_TOKEN`
Ack reactions are controlled globally via `messages.ackReaction` +
`messages.ackReactionScope`.
## Sessions + routing ## Sessions + routing
- DMs share the `main` session (like WhatsApp/Telegram). - DMs share the `main` session (like WhatsApp/Telegram).
- Channels map to `slack:channel:<channelId>` sessions. - 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). - 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. - 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. - 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`. - Mention gating precedence (most specific wins): `telegram.groups.<chatId>.requireMention``telegram.groups."*".requireMention` → default `true`.
Example config: 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 () => { it("does not override explicit values", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot"); const configDir = path.join(home, ".clawdbot");

View File

@@ -54,6 +54,32 @@ export function applyIdentityDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
return mutated ? next : cfg; 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( export function applySessionDefaults(
cfg: ClawdbotConfig, cfg: ClawdbotConfig,
options: SessionDefaultsOptions = {}, options: SessionDefaultsOptions = {},

View File

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

View File

@@ -97,6 +97,8 @@ const FIELD_LABELS: Record<string, string> = {
"ui.seamColor": "Accent Color", "ui.seamColor": "Accent Color",
"browser.controlUrl": "Browser Control URL", "browser.controlUrl": "Browser Control URL",
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns", "session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
"messages.ackReaction": "Ack Reaction Emoji",
"messages.ackReactionScope": "Ack Reaction Scope",
"talk.apiKey": "Talk API Key", "talk.apiKey": "Talk API Key",
"telegram.botToken": "Telegram Bot Token", "telegram.botToken": "Telegram Bot Token",
"discord.token": "Discord Bot Token", "discord.token": "Discord Bot Token",
@@ -131,6 +133,10 @@ const FIELD_HELP: Record<string, string> = {
"Ordered fallback image models (provider/model).", "Ordered fallback image models (provider/model).",
"session.agentToAgent.maxPingPongTurns": "session.agentToAgent.maxPingPongTurns":
"Max reply-back turns between requester and target (05).", "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> = { const FIELD_PLACEHOLDERS: Record<string, string> = {

View File

@@ -449,6 +449,10 @@ export type RoutingConfig = {
export type MessagesConfig = { export type MessagesConfig = {
messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdbot]" if no allowFrom, else "") 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., "🦞") 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"; export type BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback";

View File

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

View File

@@ -146,6 +146,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024; (opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
const textLimit = resolveTextChunkLimit(cfg, "discord"); const textLimit = resolveTextChunkLimit(cfg, "discord");
const mentionRegexes = buildMentionRegexes(cfg); const mentionRegexes = buildMentionRegexes(cfg);
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const historyLimit = Math.max( const historyLimit = Math.max(
0, 0,
opts.historyLimit ?? cfg.discord?.historyLimit ?? 20, opts.historyLimit ?? cfg.discord?.historyLimit ?? 20,
@@ -410,6 +412,27 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
logVerbose(`discord: drop message ${message.id} (empty content)`); logVerbose(`discord: drop message ${message.id} (empty content)`);
return; 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 const fromLabel = isDirectMessage
? buildDirectLabel(message) ? buildDirectLabel(message)

View File

@@ -5,6 +5,7 @@ import { monitorSlackProvider } from "./monitor.js";
const sendMock = vi.fn(); const sendMock = vi.fn();
const replyMock = vi.fn(); const replyMock = vi.fn();
const updateLastRouteMock = vi.fn(); const updateLastRouteMock = vi.fn();
const reactMock = vi.fn();
let config: Record<string, unknown> = {}; let config: Record<string, unknown> = {};
const getSlackHandlers = () => const getSlackHandlers = () =>
( (
@@ -12,6 +13,8 @@ const getSlackHandlers = () =>
__slackHandlers?: Map<string, (args: unknown) => Promise<void>>; __slackHandlers?: Map<string, (args: unknown) => Promise<void>>;
} }
).__slackHandlers; ).__slackHandlers;
const getSlackClient = () =>
(globalThis as { __slackClient?: Record<string, unknown> }).__slackClient;
vi.mock("../config/config.js", async (importOriginal) => { vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>(); 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>>(); const handlers = new Map<string, (args: unknown) => Promise<void>>();
(globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers = (globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers =
handlers; 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 { class App {
client = { client = 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" } },
}),
},
};
event(name: string, handler: (args: unknown) => Promise<void>) { event(name: string, handler: (args: unknown) => Promise<void>) {
handlers.set(name, handler); handlers.set(name, handler);
} }
@@ -76,13 +84,18 @@ async function waitForEvent(name: string) {
beforeEach(() => { beforeEach(() => {
config = { config = {
messages: { responsePrefix: "PFX" }, messages: {
responsePrefix: "PFX",
ackReaction: "👀",
ackReactionScope: "group-mentions",
},
slack: { dm: { enabled: true }, groupDm: { enabled: false } }, slack: { dm: { enabled: true }, groupDm: { enabled: false } },
routing: { allowFrom: [] }, routing: { allowFrom: [] },
}; };
sendMock.mockReset().mockResolvedValue(undefined); sendMock.mockReset().mockResolvedValue(undefined);
replyMock.mockReset(); replyMock.mockReset();
updateLastRouteMock.mockReset(); updateLastRouteMock.mockReset();
reactMock.mockReset();
}); });
describe("monitorSlackProvider tool results", () => { describe("monitorSlackProvider tool results", () => {
@@ -201,4 +214,48 @@ describe("monitorSlackProvider tool results", () => {
expect(sendMock).toHaveBeenCalledTimes(1); expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "456" }); 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 { detectMime } from "../media/mime.js";
import { saveMediaBuffer } from "../media/store.js"; import { saveMediaBuffer } from "../media/store.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { reactSlackMessage } from "./actions.js";
import { sendMessageSlack } from "./send.js"; import { sendMessageSlack } from "./send.js";
import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js"; import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
@@ -384,6 +385,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
); );
const textLimit = resolveTextChunkLimit(cfg, "slack"); const textLimit = resolveTextChunkLimit(cfg, "slack");
const mentionRegexes = buildMentionRegexes(cfg); const mentionRegexes = buildMentionRegexes(cfg);
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const mediaMaxBytes = const mediaMaxBytes =
(opts.mediaMaxMb ?? cfg.slack?.mediaMaxMb ?? 20) * 1024 * 1024; (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 || ""; const rawBody = (message.text ?? "").trim() || media?.placeholder || "";
if (!rawBody) return; 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}`; const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;

View File

@@ -25,12 +25,14 @@ const useSpy = vi.fn();
const onSpy = vi.fn(); const onSpy = vi.fn();
const stopSpy = vi.fn(); const stopSpy = vi.fn();
const sendChatActionSpy = vi.fn(); const sendChatActionSpy = vi.fn();
const setMessageReactionSpy = vi.fn(async () => undefined);
const sendMessageSpy = vi.fn(async () => ({ message_id: 77 })); const sendMessageSpy = vi.fn(async () => ({ message_id: 77 }));
const sendAnimationSpy = vi.fn(async () => ({ message_id: 78 })); const sendAnimationSpy = vi.fn(async () => ({ message_id: 78 }));
const sendPhotoSpy = vi.fn(async () => ({ message_id: 79 })); const sendPhotoSpy = vi.fn(async () => ({ message_id: 79 }));
type ApiStub = { type ApiStub = {
config: { use: (arg: unknown) => void }; config: { use: (arg: unknown) => void };
sendChatAction: typeof sendChatActionSpy; sendChatAction: typeof sendChatActionSpy;
setMessageReaction: typeof setMessageReactionSpy;
sendMessage: typeof sendMessageSpy; sendMessage: typeof sendMessageSpy;
sendAnimation: typeof sendAnimationSpy; sendAnimation: typeof sendAnimationSpy;
sendPhoto: typeof sendPhotoSpy; sendPhoto: typeof sendPhotoSpy;
@@ -38,6 +40,7 @@ type ApiStub = {
const apiStub: ApiStub = { const apiStub: ApiStub = {
config: { use: useSpy }, config: { use: useSpy },
sendChatAction: sendChatActionSpy, sendChatAction: sendChatActionSpy,
setMessageReaction: setMessageReactionSpy,
sendMessage: sendMessageSpy, sendMessage: sendMessageSpy,
sendAnimation: sendAnimationSpy, sendAnimation: sendAnimationSpy,
sendPhoto: sendPhotoSpy, sendPhoto: sendPhotoSpy,
@@ -74,6 +77,7 @@ describe("createTelegramBot", () => {
loadWebMedia.mockReset(); loadWebMedia.mockReset();
sendAnimationSpy.mockReset(); sendAnimationSpy.mockReset();
sendPhotoSpy.mockReset(); sendPhotoSpy.mockReset();
setMessageReactionSpy.mockReset();
}); });
it("installs grammY throttler", () => { it("installs grammY throttler", () => {
@@ -178,6 +182,42 @@ describe("createTelegramBot", () => {
expect(payload.WasMentioned).toBe(true); 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 () => { it("skips group messages when requireMention is enabled and no mention matches", async () => {
onSpy.mockReset(); onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType< 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 textLimit = resolveTextChunkLimit(cfg, "telegram");
const allowFrom = opts.allowFrom ?? cfg.telegram?.allowFrom; const allowFrom = opts.allowFrom ?? cfg.telegram?.allowFrom;
const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "off"; const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "off";
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const mediaMaxBytes = const mediaMaxBytes =
(opts.mediaMaxMb ?? cfg.telegram?.mediaMaxMb ?? 5) * 1024 * 1024; (opts.mediaMaxMb ?? cfg.telegram?.mediaMaxMb ?? 5) * 1024 * 1024;
const logger = getChildLogger({ module: "telegram-auto-reply" }); 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( const media = await resolveMedia(
ctx, ctx,
mediaMaxBytes, mediaMaxBytes,
@@ -200,6 +197,39 @@ export function createTelegramBot(opts: TelegramBotOptions) {
"" ""
).trim(); ).trim();
if (!rawBody) return; 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 const replySuffix = replyTarget
? `\n\n[Replying to ${replyTarget.sender}${replyTarget.id ? ` id:${replyTarget.id}` : ""}]\n${replyTarget.body}\n[/Replying]` ? `\n\n[Replying to ${replyTarget.sender}${replyTarget.id ? ` id:${replyTarget.id}` : ""}]\n${replyTarget.body}\n[/Replying]`
: ""; : "";