feat: support per-channel ackReaction config (#17092) (thanks @zerone0x)

This commit is contained in:
Shadow
2026-02-15 11:29:51 -06:00
committed by Shadow
parent b3ef3fca75
commit b6069fc68c
14 changed files with 189 additions and 4 deletions

View File

@@ -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

View File

@@ -313,6 +313,23 @@ See [Slash commands](/tools/slash-commands) for command catalog and behavior.
</Accordion>
<Accordion title="Ack reactions">
`ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message.
Resolution order:
- `channels.discord.accounts.<accountId>.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.
</Accordion>
<Accordion title="Config writes">
Channel-initiated config writes are enabled by default.

View File

@@ -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.<accountId>.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
<AccordionGroup>

View File

@@ -571,6 +571,23 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
</Accordion>
<Accordion title="Ack reactions">
`ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message.
Resolution order:
- `channels.telegram.accounts.<accountId>.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.
</Accordion>
<Accordion title="Config writes from Telegram events and commands">
Channel config writes are enabled by default (`configWrites !== false`).

View File

@@ -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.<channel>.ackReaction`, `channels.<channel>.accounts.<id>.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).

View File

@@ -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("");
});
});

View File

@@ -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<string, Record<string, unknown>> | 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;
}

View File

@@ -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. */

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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) => {

View File

@@ -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(

View File

@@ -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 = () =>

View File

@@ -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(