Ack reactions: add per-channel config overrides

This commit is contained in:
Shadow
2026-02-15 11:24:01 -06:00
parent 83e897a4cb
commit c4dc44e838
7 changed files with 135 additions and 0 deletions

View File

@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
### Changes
- 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.
- Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. Thanks @zerone0x and @thewilloftheshadow.
### 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

@@ -570,6 +570,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

@@ -1232,6 +1232,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

@@ -142,6 +142,7 @@ export const TelegramAccountSchemaBase = z
heartbeat: ChannelHeartbeatVisibilitySchema,
linkPreview: z.boolean().optional(),
responsePrefix: z.string().optional(),
ackReaction: z.string().optional(),
})
.strict();
@@ -327,6 +328,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
@@ -558,6 +560,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) => {