Compare commits

...

2 Commits

Author SHA1 Message Date
Shadow
c4dc44e838 Ack reactions: add per-channel config overrides 2026-02-15 11:24:01 -06:00
zerone0x
83e897a4cb feat: support per-channel ackReaction configuration
Different messaging platforms expect different emoji formats for reactions:
- Slack: shortcodes (e.g., "eyes")
- Telegram: unicode emoji (e.g., "👀")
- Discord: unicode or custom emoji names

This change adds channel-level ackReaction overrides, following the same
pattern as responsePrefix. Resolution order:
1. Channel account level (channels.{channel}.accounts.{id}.ackReaction)
2. Channel level (channels.{channel}.ackReaction)
3. Global messages level (messages.ackReaction)
4. Agent identity emoji fallback

Changes:
- Add ackReaction to SlackAccountConfig, TelegramAccountConfig, DiscordAccountConfig
- Update resolveAckReaction() to accept channel/accountId options
- Update callers in Telegram, Slack, Discord message handlers

Example config:
```json5
{
  messages: { ackReaction: "👀" },  // global fallback
  channels: {
    slack: { ackReaction: "eyes" },  // Slack-specific
    telegram: { ackReaction: "👀" }  // Telegram-specific
  }
}
```

Fixes #14325
2026-02-15 12:51:09 +01:00
14 changed files with 189 additions and 4 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

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

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

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

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(