From b6069fc68c2ae92e7c06967b565e363612f57903 Mon Sep 17 00:00:00 2001 From: Shadow Date: Sun, 15 Feb 2026 11:29:51 -0600 Subject: [PATCH] feat: support per-channel ackReaction config (#17092) (thanks @zerone0x) --- CHANGELOG.md | 1 + docs/channels/discord.md | 17 ++++ docs/channels/slack.md | 16 ++++ docs/channels/telegram.md | 17 ++++ docs/gateway/configuration-reference.md | 2 + src/agents/identity.test.ts | 79 +++++++++++++++++++ src/agents/identity.ts | 28 ++++++- src/config/types.discord.ts | 5 ++ src/config/types.slack.ts | 5 ++ src/config/types.telegram.ts | 5 ++ src/config/zod-schema.providers-core.ts | 3 + .../monitor/message-handler.process.ts | 5 +- src/slack/monitor/message-handler/prepare.ts | 5 +- src/telegram/bot-message-context.ts | 5 +- 14 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 src/agents/identity.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ec0070906..5f5332b5a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204. - Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow. +- Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x. ### Fixes diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 8b96ad17dd..4942797231 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -313,6 +313,23 @@ See [Slash commands](/tools/slash-commands) for command catalog and behavior. + + `ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message. + + Resolution order: + + - `channels.discord.accounts..ackReaction` + - `channels.discord.ackReaction` + - `messages.ackReaction` + - agent identity emoji fallback (`agents.list[].identity.emoji`, else "👀") + + Notes: + + - Discord accepts unicode emoji or custom emoji names. + - Use `""` to disable the reaction for a channel or account. + + + Channel-initiated config writes are enabled by default. diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 243e2f6d04..c4e95c21cf 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -287,6 +287,22 @@ Available action groups in current Slack tooling: - `channel_id_changed` can migrate channel config keys when `configWrites` is enabled. - Channel topic/purpose metadata is treated as untrusted context and can be injected into routing context. +## Ack reactions + +`ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message. + +Resolution order: + +- `channels.slack.accounts..ackReaction` +- `channels.slack.ackReaction` +- `messages.ackReaction` +- agent identity emoji fallback (`agents.list[].identity.emoji`, else "👀") + +Notes: + +- Slack expects shortcodes (for example `"eyes"`). +- Use `""` to disable the reaction for a channel or account. + ## Manifest and scope checklist diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index a919d20b0c..28a9c227f9 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -571,6 +571,23 @@ curl "https://api.telegram.org/bot/getUpdates" + + `ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message. + + Resolution order: + + - `channels.telegram.accounts..ackReaction` + - `channels.telegram.ackReaction` + - `messages.ackReaction` + - agent identity emoji fallback (`agents.list[].identity.emoji`, else "👀") + + Notes: + + - Telegram expects unicode emoji (for example "👀"). + - Use `""` to disable the reaction for a channel or account. + + + Channel config writes are enabled by default (`configWrites !== false`). diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 4a02e4ff44..eeb1eaea7b 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1238,6 +1238,8 @@ Variables are case-insensitive. `{think}` is an alias for `{thinkingLevel}`. ### Ack reaction - Defaults to active agent's `identity.emoji`, otherwise `"👀"`. Set `""` to disable. +- Per-channel overrides: `channels..ackReaction`, `channels..accounts..ackReaction`. +- Resolution order: account → channel → `messages.ackReaction` → identity fallback. - Scope: `group-mentions` (default), `group-all`, `direct`, `all`. - `removeAckAfterReply`: removes ack after reply (Slack/Discord/Telegram/Google Chat only). diff --git a/src/agents/identity.test.ts b/src/agents/identity.test.ts new file mode 100644 index 0000000000..7ff865fe14 --- /dev/null +++ b/src/agents/identity.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveAckReaction } from "./identity.js"; + +describe("resolveAckReaction", () => { + it("prefers account-level overrides", () => { + const cfg: OpenClawConfig = { + messages: { ackReaction: "👀" }, + agents: { list: [{ id: "main", identity: { emoji: "✅" } }] }, + channels: { + slack: { + ackReaction: "eyes", + accounts: { + acct1: { ackReaction: " party_parrot " }, + }, + }, + }, + }; + + expect(resolveAckReaction(cfg, "main", { channel: "slack", accountId: "acct1" })).toBe( + "party_parrot", + ); + }); + + it("falls back to channel-level overrides", () => { + const cfg: OpenClawConfig = { + messages: { ackReaction: "👀" }, + agents: { list: [{ id: "main", identity: { emoji: "✅" } }] }, + channels: { + slack: { + ackReaction: "eyes", + accounts: { + acct1: { ackReaction: "party_parrot" }, + }, + }, + }, + }; + + expect(resolveAckReaction(cfg, "main", { channel: "slack", accountId: "missing" })).toBe( + "eyes", + ); + }); + + it("uses the global ackReaction when channel overrides are missing", () => { + const cfg: OpenClawConfig = { + messages: { ackReaction: "✅" }, + agents: { list: [{ id: "main", identity: { emoji: "😺" } }] }, + }; + + expect(resolveAckReaction(cfg, "main", { channel: "discord" })).toBe("✅"); + }); + + it("falls back to the agent identity emoji when global config is unset", () => { + const cfg: OpenClawConfig = { + agents: { list: [{ id: "main", identity: { emoji: "🔥" } }] }, + }; + + expect(resolveAckReaction(cfg, "main", { channel: "discord" })).toBe("🔥"); + }); + + it("returns the default emoji when no config is present", () => { + const cfg: OpenClawConfig = {}; + + expect(resolveAckReaction(cfg, "main")).toBe("👀"); + }); + + it("allows empty strings to disable reactions", () => { + const cfg: OpenClawConfig = { + messages: { ackReaction: "👀" }, + channels: { + telegram: { + ackReaction: "", + }, + }, + }; + + expect(resolveAckReaction(cfg, "main", { channel: "telegram" })).toBe(""); + }); +}); diff --git a/src/agents/identity.ts b/src/agents/identity.ts index 1ce3831ad9..ae27c88149 100644 --- a/src/agents/identity.ts +++ b/src/agents/identity.ts @@ -10,11 +10,37 @@ export function resolveAgentIdentity( return resolveAgentConfig(cfg, agentId)?.identity; } -export function resolveAckReaction(cfg: OpenClawConfig, agentId: string): string { +export function resolveAckReaction( + cfg: OpenClawConfig, + agentId: string, + opts?: { channel?: string; accountId?: string }, +): string { + // L1: Channel account level + if (opts?.channel && opts?.accountId) { + const channelCfg = getChannelConfig(cfg, opts.channel); + const accounts = channelCfg?.accounts as Record> | undefined; + const accountReaction = accounts?.[opts.accountId]?.ackReaction as string | undefined; + if (accountReaction !== undefined) { + return accountReaction.trim(); + } + } + + // L2: Channel level + if (opts?.channel) { + const channelCfg = getChannelConfig(cfg, opts.channel); + const channelReaction = channelCfg?.ackReaction as string | undefined; + if (channelReaction !== undefined) { + return channelReaction.trim(); + } + } + + // L3: Global messages level const configured = cfg.messages?.ackReaction; if (configured !== undefined) { return configured.trim(); } + + // L4: Agent identity emoji fallback const emoji = resolveAgentIdentity(cfg, agentId)?.emoji?.trim(); return emoji || DEFAULT_ACK_REACTION; } diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index c9ba2ed6f3..b2e1652907 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -200,6 +200,11 @@ export type DiscordAccountConfig = { pluralkit?: DiscordPluralKitConfig; /** Outbound response prefix override for this channel/account. */ responsePrefix?: string; + /** + * Per-channel ack reaction override. + * Discord supports both unicode emoji and custom emoji names. + */ + ackReaction?: string; /** Bot activity status text (e.g. "Watching X"). */ activity?: string; /** Bot status (online|dnd|idle|invisible). Defaults to online when presence is configured. */ diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index a7f7bef2c8..ead656cce2 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -156,6 +156,11 @@ export type SlackAccountConfig = { heartbeat?: ChannelHeartbeatVisibilityConfig; /** Outbound response prefix override for this channel/account. */ responsePrefix?: string; + /** + * Per-channel ack reaction override. + * Slack uses shortcodes (e.g., "eyes") rather than unicode emoji. + */ + ackReaction?: string; }; export type SlackConfig = { diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 99b28cf793..d8e189e756 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -141,6 +141,11 @@ export type TelegramAccountConfig = { * Use `"auto"` to derive `[{identity.name}]` from the routed agent. */ responsePrefix?: string; + /** + * Per-channel ack reaction override. + * Telegram expects unicode emoji (e.g., "👀") rather than shortcodes. + */ + ackReaction?: string; }; export type TelegramTopicConfig = { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index eaca32ffa5..ed40d5e62b 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -143,6 +143,7 @@ export const TelegramAccountSchemaBase = z heartbeat: ChannelHeartbeatVisibilitySchema, linkPreview: z.boolean().optional(), responsePrefix: z.string().optional(), + ackReaction: z.string().optional(), }) .strict(); @@ -341,6 +342,7 @@ export const DiscordAccountSchema = z .strict() .optional(), responsePrefix: z.string().optional(), + ackReaction: z.string().optional(), activity: z.string().optional(), status: z.enum(["online", "dnd", "idle", "invisible"]).optional(), activityType: z @@ -572,6 +574,7 @@ export const SlackAccountSchema = z channels: z.record(z.string(), SlackChannelSchema.optional()).optional(), heartbeat: ChannelHeartbeatVisibilitySchema, responsePrefix: z.string().optional(), + ackReaction: z.string().optional(), }) .strict() .superRefine((value, ctx) => { diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index e0d849d40e..04e45af24f 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -88,7 +88,10 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) logVerbose(`discord: drop message ${message.id} (empty content)`); return; } - const ackReaction = resolveAckReaction(cfg, route.agentId); + const ackReaction = resolveAckReaction(cfg, route.agentId, { + channel: "discord", + accountId, + }); const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; const shouldAckReaction = () => Boolean( diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 72b7d9271f..d7296646d5 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -348,7 +348,10 @@ export async function prepareSlackMessage(params: { return null; } - const ackReaction = resolveAckReaction(cfg, route.agentId); + const ackReaction = resolveAckReaction(cfg, route.agentId, { + channel: "slack", + accountId: account.accountId, + }); const ackReactionValue = ackReaction ?? ""; const shouldAckReaction = () => diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index 9cd8f91106..9723419f4d 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -502,7 +502,10 @@ export const buildTelegramMessageContext = async ({ } // ACK reactions - const ackReaction = resolveAckReaction(cfg, route.agentId); + const ackReaction = resolveAckReaction(cfg, route.agentId, { + channel: "telegram", + accountId: account.accountId, + }); const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; const shouldAckReaction = () => Boolean(