mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
feat: support per-channel ackReaction config (#17092) (thanks @zerone0x)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
79
src/agents/identity.test.ts
Normal file
79
src/agents/identity.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user