feat: add removeAckAfterReply option for Discord, Slack, and Telegram

Add `messages.removeAckAfterReply` config option to automatically remove
acknowledgment reactions after the bot sends a reply, reducing visual
clutter while still providing immediate feedback.

Platforms: Discord, Slack, Telegram

Implementation:
- Added removeAckAfterReply boolean field to MessagesConfig (default: false)
- Track ack reaction state in all three platform handlers
- Remove ack reaction after successful reply delivery
- Graceful error handling with verbose logging

Platform-specific:
- Discord: uses removeReactionDiscord()
- Slack: uses removeSlackReaction()
- Telegram: uses setMessageReaction() with empty array

Closes #627
This commit is contained in:
Levi Figueira
2026-01-10 00:31:12 +00:00
committed by Peter Steinberger
parent a29f5dda2e
commit b5858c0148
5 changed files with 53 additions and 2 deletions

View File

@@ -999,6 +999,8 @@ export type MessagesConfig = {
ackReaction?: string; ackReaction?: string;
/** When to send ack reactions. Default: "group-mentions". */ /** When to send ack reactions. Default: "group-mentions". */
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all"; ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all";
/** Remove ack reaction after reply is sent (default: false). */
removeAckAfterReply?: boolean;
}; };
export type CommandsConfig = { export type CommandsConfig = {

View File

@@ -603,6 +603,7 @@ const MessagesSchema = z
ackReactionScope: z ackReactionScope: z
.enum(["group-mentions", "group-all", "direct", "all"]) .enum(["group-mentions", "group-all", "direct", "all"])
.optional(), .optional(),
removeAckAfterReply: z.boolean().optional(),
}) })
.optional(); .optional();

View File

@@ -74,7 +74,11 @@ import {
waitForDiscordGatewayStop, waitForDiscordGatewayStop,
} from "./monitor.gateway.js"; } from "./monitor.gateway.js";
import { fetchDiscordApplicationId } from "./probe.js"; import { fetchDiscordApplicationId } from "./probe.js";
import { reactMessageDiscord, sendMessageDiscord } from "./send.js"; import {
reactMessageDiscord,
removeReactionDiscord,
sendMessageDiscord,
} from "./send.js";
import { normalizeDiscordToken } from "./token.js"; import { normalizeDiscordToken } from "./token.js";
export type MonitorDiscordOpts = { export type MonitorDiscordOpts = {
@@ -958,6 +962,7 @@ export function createDiscordMessageHandler(params: {
return; return;
} }
const ackReaction = resolveAckReaction(cfg, route.agentId); const ackReaction = resolveAckReaction(cfg, route.agentId);
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const shouldAckReaction = () => { const shouldAckReaction = () => {
if (!ackReaction) return false; if (!ackReaction) return false;
if (ackReactionScope === "all") return true; if (ackReactionScope === "all") return true;
@@ -972,6 +977,7 @@ export function createDiscordMessageHandler(params: {
} }
return false; return false;
}; };
let didAddAckReaction = false;
if (shouldAckReaction()) { if (shouldAckReaction()) {
reactMessageDiscord(message.channelId, message.id, ackReaction, { reactMessageDiscord(message.channelId, message.id, ackReaction, {
rest: client.rest, rest: client.rest,
@@ -980,6 +986,7 @@ export function createDiscordMessageHandler(params: {
`discord react failed for channel ${message.channelId}: ${String(err)}`, `discord react failed for channel ${message.channelId}: ${String(err)}`,
); );
}); });
didAddAckReaction = true;
} }
const fromLabel = isDirectMessage const fromLabel = isDirectMessage
@@ -1201,6 +1208,15 @@ export function createDiscordMessageHandler(params: {
`discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`, `discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
); );
} }
if (removeAckAfterReply && didAddAckReaction && ackReaction) {
removeReactionDiscord(message.channelId, message.id, ackReaction, {
rest: client.rest,
}).catch((err) => {
logVerbose(
`discord: failed to remove ack reaction from ${message.channelId}/${message.id}: ${String(err)}`,
);
});
}
if ( if (
isGuildMessage && isGuildMessage &&
shouldClearHistory && shouldClearHistory &&

View File

@@ -59,7 +59,7 @@ import {
} from "../routing/session-key.js"; } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { resolveSlackAccount } from "./accounts.js"; import { resolveSlackAccount } from "./accounts.js";
import { reactSlackMessage } from "./actions.js"; import { reactSlackMessage, removeSlackReaction } from "./actions.js";
import { sendMessageSlack } from "./send.js"; import { sendMessageSlack } from "./send.js";
import { resolveSlackThreadTargets } from "./threading.js"; import { resolveSlackThreadTargets } from "./threading.js";
import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js"; import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
@@ -913,6 +913,7 @@ 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 ackReaction = resolveAckReaction(cfg, route.agentId); const ackReaction = resolveAckReaction(cfg, route.agentId);
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const shouldAckReaction = () => { const shouldAckReaction = () => {
if (!ackReaction) return false; if (!ackReaction) return false;
if (ackReactionScope === "all") return true; if (ackReactionScope === "all") return true;
@@ -927,6 +928,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
} }
return false; return false;
}; };
let didAddAckReaction = false;
if (shouldAckReaction() && message.ts) { if (shouldAckReaction() && message.ts) {
reactSlackMessage(message.channel, message.ts, ackReaction, { reactSlackMessage(message.channel, message.ts, ackReaction, {
token: botToken, token: botToken,
@@ -936,6 +938,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
`slack react failed for channel ${message.channel}: ${String(err)}`, `slack react failed for channel ${message.channel}: ${String(err)}`,
); );
}); });
didAddAckReaction = true;
} }
const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`; const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;
@@ -1157,6 +1160,16 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
`slack: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`, `slack: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
); );
} }
if (removeAckAfterReply && didAddAckReaction && ackReaction && message.ts) {
removeSlackReaction(message.channel, message.ts, ackReaction, {
token: botToken,
client: app.client,
}).catch((err) => {
logVerbose(
`slack: failed to remove ack reaction from ${message.channel}/${message.ts}: ${String(err)}`,
);
});
}
}; };
app.event( app.event(

View File

@@ -577,6 +577,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
// ACK reactions // ACK reactions
const ackReaction = resolveAckReaction(cfg, route.agentId); const ackReaction = resolveAckReaction(cfg, route.agentId);
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const shouldAckReaction = () => { const shouldAckReaction = () => {
if (!ackReaction) return false; if (!ackReaction) return false;
if (ackReactionScope === "all") return true; if (ackReactionScope === "all") return true;
@@ -590,6 +591,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
} }
return false; return false;
}; };
let didAddAckReaction = false;
if (shouldAckReaction() && msg.message_id) { if (shouldAckReaction() && msg.message_id) {
const api = bot.api as unknown as { const api = bot.api as unknown as {
setMessageReaction?: ( setMessageReaction?: (
@@ -608,6 +610,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
`telegram react failed for chat ${chatId}: ${String(err)}`, `telegram react failed for chat ${chatId}: ${String(err)}`,
); );
}); });
didAddAckReaction = true;
} }
} }
@@ -854,6 +857,22 @@ export function createTelegramBot(opts: TelegramBotOptions) {
markDispatchIdle(); markDispatchIdle();
draftStream?.stop(); draftStream?.stop();
if (!queuedFinal) return; if (!queuedFinal) return;
if (removeAckAfterReply && didAddAckReaction && 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, []).catch((err) => {
logVerbose(
`telegram: failed to remove ack reaction from ${chatId}/${msg.message_id}: ${String(err)}`,
);
});
}
}
}; };
const nativeCommands = nativeEnabled ? listNativeCommandSpecs() : []; const nativeCommands = nativeEnabled ? listNativeCommandSpecs() : [];