diff --git a/CHANGELOG.md b/CHANGELOG.md index a4a3ec328..b9b120b0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc - Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro - Slack: configurable reply threading (`slack.replyToMode`) + proper mrkdwn formatting for outbound messages. (#464) — thanks @austinm911 +- Providers: remove ack reactions after reply on Discord/Slack/Telegram. (#633) — thanks @levifig - Discord: avoid category parent overrides for channel allowlists and refactor thread context helpers. (#588) — thanks @steipete - Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow - Discord: log gateway disconnect/reconnect events at info and add verbose gateway metrics. (#595) — thanks @steipete diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index b427da800..ccde7c27a 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -948,7 +948,8 @@ See [Messages](/concepts/messages) for queueing, sessions, and streaming context messages: { responsePrefix: "🦞", // or "auto" ackReaction: "👀", - ackReactionScope: "group-mentions" + ackReactionScope: "group-mentions", + removeAckAfterReply: false } } ``` @@ -975,6 +976,9 @@ active agent’s `identity.emoji` when set, otherwise `"👀"`. Set it to `""` t - `direct`: direct messages only - `all`: all messages +`removeAckAfterReply` removes the bot’s ack reaction after a reply is sent +(Slack/Discord/Telegram only). Default: `false`. + ### `talk` Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` when unset. diff --git a/docs/providers/discord.md b/docs/providers/discord.md index a70876cbf..1ec08c00c 100644 --- a/docs/providers/discord.md +++ b/docs/providers/discord.md @@ -225,7 +225,8 @@ Outbound Discord API calls retry on rate limits (429) using Discord `retry_after ``` Ack reactions are controlled globally via `messages.ackReaction` + -`messages.ackReactionScope`. +`messages.ackReactionScope`. Use `messages.removeAckAfterReply` to clear the +ack reaction after the bot replies. - `dm.enabled`: set `false` to ignore all DMs (default `true`). - `dm.policy`: DM access control (`pairing` recommended). `"open"` requires `dm.allowFrom=["*"]`. diff --git a/docs/providers/slack.md b/docs/providers/slack.md index c95e23a0a..927030b57 100644 --- a/docs/providers/slack.md +++ b/docs/providers/slack.md @@ -192,7 +192,8 @@ Tokens can also be supplied via env vars: - `SLACK_APP_TOKEN` Ack reactions are controlled globally via `messages.ackReaction` + -`messages.ackReactionScope`. +`messages.ackReactionScope`. Use `messages.removeAckAfterReply` to clear the +ack reaction after the bot replies. ## Limits - Outbound text is chunked to `slack.textChunkLimit` (default 4000). diff --git a/docs/providers/telegram.md b/docs/providers/telegram.md index fce881f1a..f37874b27 100644 --- a/docs/providers/telegram.md +++ b/docs/providers/telegram.md @@ -287,4 +287,4 @@ Related global options: - `agents.list[].groupChat.mentionPatterns` (mention gating patterns). - `messages.groupChat.mentionPatterns` (global fallback). - `commands.native`, `commands.text`, `commands.useAccessGroups` (command behavior). -- `messages.responsePrefix`, `messages.ackReaction`, `messages.ackReactionScope`. +- `messages.responsePrefix`, `messages.ackReaction`, `messages.ackReactionScope`, `messages.removeAckAfterReply`. diff --git a/src/config/types.ts b/src/config/types.ts index bbedafe72..2342da12c 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -999,6 +999,8 @@ export type MessagesConfig = { ackReaction?: string; /** When to send ack reactions. Default: "group-mentions". */ ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all"; + /** Remove ack reaction after reply is sent (default: false). */ + removeAckAfterReply?: boolean; }; export type CommandsConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 56266928e..36a808889 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -603,6 +603,7 @@ const MessagesSchema = z ackReactionScope: z .enum(["group-mentions", "group-all", "direct", "all"]) .optional(), + removeAckAfterReply: z.boolean().optional(), }) .optional(); diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index dc30ab682..6dffa3443 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -74,7 +74,11 @@ import { waitForDiscordGatewayStop, } from "./monitor.gateway.js"; import { fetchDiscordApplicationId } from "./probe.js"; -import { reactMessageDiscord, sendMessageDiscord } from "./send.js"; +import { + reactMessageDiscord, + removeReactionDiscord, + sendMessageDiscord, +} from "./send.js"; import { normalizeDiscordToken } from "./token.js"; export type MonitorDiscordOpts = { @@ -958,6 +962,7 @@ export function createDiscordMessageHandler(params: { return; } const ackReaction = resolveAckReaction(cfg, route.agentId); + const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; const shouldAckReaction = () => { if (!ackReaction) return false; if (ackReactionScope === "all") return true; @@ -972,15 +977,19 @@ export function createDiscordMessageHandler(params: { } return false; }; - if (shouldAckReaction()) { - reactMessageDiscord(message.channelId, message.id, ackReaction, { - rest: client.rest, - }).catch((err) => { - logVerbose( - `discord react failed for channel ${message.channelId}: ${String(err)}`, - ); - }); - } + const ackReactionPromise = shouldAckReaction() + ? reactMessageDiscord(message.channelId, message.id, ackReaction, { + rest: client.rest, + }).then( + () => true, + (err) => { + logVerbose( + `discord react failed for channel ${message.channelId}: ${String(err)}`, + ); + return false; + }, + ) + : null; const fromLabel = isDirectMessage ? buildDirectLabel(author) @@ -1201,6 +1210,24 @@ export function createDiscordMessageHandler(params: { `discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`, ); } + if (removeAckAfterReply && ackReactionPromise && ackReaction) { + const ackReactionValue = ackReaction; + void ackReactionPromise.then((didAck) => { + if (!didAck) return; + removeReactionDiscord( + message.channelId, + message.id, + ackReactionValue, + { + rest: client.rest, + }, + ).catch((err) => { + logVerbose( + `discord: failed to remove ack reaction from ${message.channelId}/${message.id}: ${String(err)}`, + ); + }); + }); + } if ( isGuildMessage && shouldClearHistory && diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index 5ddbd9de2..d95443b3c 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -59,7 +59,7 @@ import { } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveSlackAccount } from "./accounts.js"; -import { reactSlackMessage } from "./actions.js"; +import { reactSlackMessage, removeSlackReaction } from "./actions.js"; import { sendMessageSlack } from "./send.js"; import { resolveSlackThreadTargets } from "./threading.js"; import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js"; @@ -913,6 +913,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const rawBody = (message.text ?? "").trim() || media?.placeholder || ""; if (!rawBody) return; const ackReaction = resolveAckReaction(cfg, route.agentId); + const ackReactionValue = ackReaction ?? ""; + const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; const shouldAckReaction = () => { if (!ackReaction) return false; if (ackReactionScope === "all") return true; @@ -927,16 +929,27 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { } 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 ackReactionMessageTs = message.ts; + const ackReactionPromise = + shouldAckReaction() && ackReactionMessageTs && ackReactionValue + ? reactSlackMessage( + message.channel, + ackReactionMessageTs, + ackReactionValue, + { + token: botToken, + client: app.client, + }, + ).then( + () => true, + (err) => { + logVerbose( + `slack react failed for channel ${message.channel}: ${String(err)}`, + ); + return false; + }, + ) + : null; const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`; @@ -1157,6 +1170,20 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { `slack: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`, ); } + if (removeAckAfterReply && ackReactionPromise && ackReactionMessageTs) { + const messageTs = ackReactionMessageTs; + void ackReactionPromise.then((didAck) => { + if (!didAck) return; + removeSlackReaction(message.channel, messageTs, ackReactionValue, { + token: botToken, + client: app.client, + }).catch((err) => { + logVerbose( + `slack: failed to remove ack reaction from ${message.channel}/${message.ts}: ${String(err)}`, + ); + }); + }); + } }; app.event( diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 8ec6756b4..1a3f84b8d 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -577,6 +577,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { // ACK reactions const ackReaction = resolveAckReaction(cfg, route.agentId); + const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; const shouldAckReaction = () => { if (!ackReaction) return false; if (ackReactionScope === "all") return true; @@ -590,26 +591,31 @@ export function createTelegramBot(opts: TelegramBotOptions) { } 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; - }; - if (typeof api.setMessageReaction === "function") { - api - .setMessageReaction(chatId, msg.message_id, [ + const api = bot.api as unknown as { + setMessageReaction?: ( + chatId: number | string, + messageId: number, + reactions: Array<{ type: "emoji"; emoji: string }>, + ) => Promise; + }; + const reactionApi = + typeof api.setMessageReaction === "function" + ? api.setMessageReaction.bind(api) + : null; + const ackReactionPromise = + shouldAckReaction() && msg.message_id && reactionApi + ? reactionApi(chatId, msg.message_id, [ { type: "emoji", emoji: ackReaction }, - ]) - .catch((err) => { - logVerbose( - `telegram react failed for chat ${chatId}: ${String(err)}`, - ); - }); - } - } + ]).then( + () => true, + (err) => { + logVerbose( + `telegram react failed for chat ${chatId}: ${String(err)}`, + ); + return false; + }, + ) + : null; let placeholder = ""; if (msg.photo) placeholder = ""; @@ -854,6 +860,21 @@ export function createTelegramBot(opts: TelegramBotOptions) { markDispatchIdle(); draftStream?.stop(); if (!queuedFinal) return; + if ( + removeAckAfterReply && + ackReactionPromise && + msg.message_id && + reactionApi + ) { + void ackReactionPromise.then((didAck) => { + if (!didAck) return; + reactionApi(chatId, msg.message_id, []).catch((err) => { + logVerbose( + `telegram: failed to remove ack reaction from ${chatId}/${msg.message_id}: ${String(err)}`, + ); + }); + }); + } }; const nativeCommands = nativeEnabled ? listNativeCommandSpecs() : [];