Skip to content

Commit 9b80983

Browse files
committed
fix(channels): align all ack scope for room events
1 parent 4bf3722 commit 9b80983

8 files changed

Lines changed: 128 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
2323

2424
- Security/CLI/runtime: harden hostname normalization for repeated trailing dots, block side-effecting command wrappers, reject unsafe Node runtime env overrides, reject loose numeric CLI and gateway options, require admin approval for node device-role pairing, and reject no-auth Tailscale exposure. (#87305, #87292, #87308, #87146) Thanks @pgondhi987.
2525
- Telegram: route `sendMessage` action replies through durable outbound delivery so completed agent responses remain retryable when the gateway send path times out. (#87261) Thanks @mbelinky.
26+
- Discord/Slack/Telegram: honor `messages.ackReactionScope: "all"` for ambient room-event ack reactions while keeping lifecycle status reactions suppressed for room events. (#87368)
2627
- Matrix/auto-reply: keep draft previews mention-inert, preserve final mention delivery, send mention finals normally, await shared DM notices, ignore filename-embedded MXIDs, and suppress reasoning-prefixed `NO_REPLY` responses.
2728
- Agents/providers: add OpenAI-compatible cache retention, forward cached token usage in chat completions, preserve runtime context before active user turns, strip stale Anthropic thinking, load Claude CLI OAuth for Pi auth profiles, avoid false Codex runtime live switches, and quarantine unsupported tool schemas. (#82062, #87167, #86855)
2829
- Gateway/performance: cache plugin metadata fingerprints and stable plugin index fingerprints, borrow read-only session metadata safely, keep the active session working store hot, keep status on a bounded fast path, and preserve model auth profile suffixes. (#86439)

docs/channels/discord.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -898,6 +898,14 @@ Default slash command settings:
898898
- Discord accepts unicode emoji or custom emoji names.
899899
- Use `""` to disable the reaction for a channel or account.
900900

901+
**Scope (`messages.ackReactionScope`):**
902+
903+
Values: `"all"` (DMs + groups, including ambient room events), `"direct"` (DMs only), `"group-all"` (every group message, no DMs), `"group-mentions"` (groups when the bot is mentioned; **no DMs**, default), `"off"` / `"none"` (disabled).
904+
905+
<Note>
906+
The default scope (`"group-mentions"`) does not fire ack reactions in direct messages or ambient room events. To get an ack reaction on inbound Discord DMs and quiet room events, set `messages.ackReactionScope` to `"all"`.
907+
</Note>
908+
901909
</Accordion>
902910

903911
<Accordion title="Config writes">

docs/channels/slack.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,14 +1070,14 @@ The Slack provider reads scope from `messages.ackReactionScope` (default `"group
10701070

10711071
Values:
10721072

1073-
- `"all"`: react in DMs and groups.
1073+
- `"all"`: react in DMs and groups, including ambient room events.
10741074
- `"direct"`: react in DMs only.
10751075
- `"group-all"`: react on every group message (no DMs).
10761076
- `"group-mentions"` (default): react in groups, but only when the bot is mentioned (or in group mentionables that opted in). **DMs are excluded.**
10771077
- `"off"` / `"none"`: never react.
10781078

10791079
<Note>
1080-
The default scope (`"group-mentions"`) does not fire ack reactions in direct messages. To see the configured `ackReaction` (for example `"eyes"`) on inbound Slack DMs, set `messages.ackReactionScope` to `"direct"` or `"all"`. `messages.ackReactionScope` is read at Slack provider startup, so a gateway restart is needed for the change to take effect.
1080+
The default scope (`"group-mentions"`) does not fire ack reactions in direct messages or ambient room events. To see the configured `ackReaction` (for example `"eyes"`) on inbound Slack DMs and quiet room events, set `messages.ackReactionScope` to `"all"`. `messages.ackReactionScope` is read at Slack provider startup, so a gateway restart is needed for the change to take effect.
10811081
</Note>
10821082

10831083
```json5

docs/channels/telegram.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -816,10 +816,10 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
816816

817817
The Telegram provider reads scope from `messages.ackReactionScope` (default `"group-mentions"`). There is no Telegram-account or Telegram-channel-level override today.
818818

819-
Values: `"all"` (DMs + groups), `"direct"` (DMs only), `"group-all"` (every group message, no DMs), `"group-mentions"` (groups when the bot is mentioned; **no DMs** — this is the default), `"off"` / `"none"` (disabled).
819+
Values: `"all"` (DMs + groups, including ambient room events), `"direct"` (DMs only), `"group-all"` (every group message, no DMs), `"group-mentions"` (groups when the bot is mentioned; **no DMs**, default), `"off"` / `"none"` (disabled).
820820

821821
<Note>
822-
The default scope (`"group-mentions"`) does not fire ack reactions in direct messages. To get an ack reaction on inbound Telegram DMs, set `messages.ackReactionScope` to `"direct"` or `"all"`. The value is read at Telegram provider startup, so a gateway restart is needed for the change to take effect.
822+
The default scope (`"group-mentions"`) does not fire ack reactions in direct messages or ambient room events. To get an ack reaction on inbound Telegram DMs and quiet room events, set `messages.ackReactionScope` to `"all"`. The value is read at Telegram provider startup, so a gateway restart is needed for the change to take effect.
823823
</Note>
824824

825825
</Accordion>

extensions/slack/src/monitor/message-handler/prepare.test.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -743,12 +743,12 @@ describe("slack prepareSlackMessage inbound contract", () => {
743743
expect(await prepared.ackReactionPromise).toBe(true);
744744
});
745745

746-
it("keeps unmentioned room events quiet even when group replies are automatic", async () => {
746+
it("keeps unmentioned room events quiet when ack scope does not force all messages", async () => {
747747
const slackCtx = createInboundSlackCtx({
748748
cfg: {
749749
messages: {
750750
ackReaction: "eyes",
751-
ackReactionScope: "all",
751+
ackReactionScope: "group-all",
752752
groupChat: {
753753
unmentionedInbound: "room_event",
754754
visibleReplies: "automatic",
@@ -766,6 +766,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
766766
});
767767
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
768768
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
769+
slackCtx.ackReactionScope = "group-all";
769770

770771
const prepared = await prepareMessageWith(slackCtx, defaultAccount, {
771772
channel: "C123",
@@ -781,6 +782,54 @@ describe("slack prepareSlackMessage inbound contract", () => {
781782
expect(prepared.ackReactionPromise).toBeNull();
782783
});
783784

785+
it("sends Slack ack reactions for room events when ack scope is all", async () => {
786+
const reactionAdd = vi.fn().mockResolvedValue({ ok: true });
787+
const slackCtx = createInboundSlackCtx({
788+
cfg: {
789+
messages: {
790+
ackReaction: "eyes",
791+
ackReactionScope: "all",
792+
groupChat: {
793+
unmentionedInbound: "room_event",
794+
visibleReplies: "automatic",
795+
},
796+
statusReactions: { enabled: true },
797+
},
798+
channels: {
799+
slack: {
800+
enabled: true,
801+
groupPolicy: "open",
802+
},
803+
},
804+
} as OpenClawConfig,
805+
appClient: { reactions: { add: reactionAdd } } as any,
806+
defaultRequireMention: false,
807+
});
808+
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
809+
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
810+
slackCtx.ackReactionScope = "all";
811+
812+
const prepared = await prepareMessageWith(slackCtx, defaultAccount, {
813+
channel: "C123",
814+
channel_type: "channel",
815+
user: "U1",
816+
text: "ambient note",
817+
ts: "1.000",
818+
} as SlackMessageEvent);
819+
820+
assertPrepared(prepared);
821+
expect(prepared.ctxPayload.InboundEventKind).toBe("room_event");
822+
expect(prepared.ackReactionMessageTs).toBe("1.000");
823+
expect(prepared.ackReactionValue).toBe("eyes");
824+
expect(prepared.ackReactionPromise).toBeInstanceOf(Promise);
825+
expect(await prepared.ackReactionPromise).toBe(true);
826+
expect(reactionAdd).toHaveBeenCalledWith({
827+
channel: "C123",
828+
name: "eyes",
829+
timestamp: "1.000",
830+
});
831+
});
832+
784833
it("keeps unmentioned abort requests as user requests when room events are enabled", async () => {
785834
const slackCtx = createInboundSlackCtx({
786835
cfg: {

extensions/slack/src/monitor/message-handler/prepare.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1055,8 +1055,11 @@ export async function prepareSlackMessage(params: {
10551055
ctx: { ChatType: chatType, InboundEventKind: inboundEventKind },
10561056
}) === "message_tool_only";
10571057
const statusReactionsExplicitlyEnabled = cfg.messages?.statusReactions?.enabled === true;
1058+
const isRoomEvent = inboundEventKind === "room_event";
1059+
const forceAckForAllScope = ctx.ackReactionScope === "all";
10581060
const shouldAckReaction = () =>
10591061
Boolean(
1062+
(forceAckForAllScope || !isRoomEvent) &&
10601063
ackReaction &&
10611064
shouldAckReactionGate({
10621065
scope: ctx.ackReactionScope as AckReactionScope | undefined,
@@ -1074,9 +1077,11 @@ export async function prepareSlackMessage(params: {
10741077
const allowToolOnlyStatusReaction =
10751078
statusReactionsExplicitlyEnabled && (effectiveWasMentioned || shouldBypassMention);
10761079
const shouldSendAckReaction =
1077-
shouldAckReaction() && (!sourceRepliesAreToolOnly || allowToolOnlyStatusReaction);
1080+
shouldAckReaction() &&
1081+
(!sourceRepliesAreToolOnly || allowToolOnlyStatusReaction || forceAckForAllScope);
10781082
const statusReactionsWillHandle =
10791083
Boolean(ackReactionMessageTs) &&
1084+
!isRoomEvent &&
10801085
cfg.messages?.statusReactions?.enabled !== false &&
10811086
shouldSendAckReaction;
10821087
const ackReactionPromise =

extensions/telegram/src/bot-message-context.reactions.test.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ describe("buildTelegramMessageContext reactions", () => {
6262
inboundBodyMock.mockClear();
6363
});
6464

65-
it("does not create ack or status reactions for room events", async () => {
65+
it("does not create ack or status reactions for room events when scope does not force all messages", async () => {
6666
const setMessageReaction = vi.fn(async () => undefined);
6767
const { createStatusReactionController } = createStatusReactionControllerStub();
6868

@@ -90,7 +90,7 @@ describe("buildTelegramMessageContext reactions", () => {
9090
statusReactions: { enabled: true },
9191
},
9292
},
93-
ackReactionScope: "all",
93+
ackReactionScope: "group-all",
9494
botApi: { setMessageReaction },
9595
runtime: { createStatusReactionController },
9696
resolveGroupActivation: () => false,
@@ -108,6 +108,54 @@ describe("buildTelegramMessageContext reactions", () => {
108108
expect(setMessageReaction).not.toHaveBeenCalled();
109109
});
110110

111+
it("sends Telegram ack reactions for room events when ack scope is all", async () => {
112+
const setMessageReaction = vi.fn(async () => undefined);
113+
const { createStatusReactionController } = createStatusReactionControllerStub();
114+
115+
const ctx = await buildTelegramMessageContextForTest({
116+
message: {
117+
message_id: 12,
118+
chat: { id: -1001234567890, type: "group", title: "Ops" },
119+
date: 1_700_000_000,
120+
text: "hello",
121+
from: { id: 42, first_name: "Alice" },
122+
},
123+
cfg: {
124+
agents: {
125+
defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" },
126+
},
127+
channels: {
128+
telegram: {
129+
groupPolicy: "open",
130+
groups: { "*": { requireMention: false } },
131+
},
132+
},
133+
messages: {
134+
ackReaction: "👀",
135+
groupChat: { unmentionedInbound: "room_event", mentionPatterns: [] },
136+
statusReactions: { enabled: true },
137+
},
138+
},
139+
ackReactionScope: "all",
140+
botApi: { setMessageReaction },
141+
runtime: { createStatusReactionController },
142+
resolveGroupActivation: () => false,
143+
resolveGroupRequireMention: () => false,
144+
resolveTelegramGroupConfig: () => ({
145+
groupConfig: { requireMention: false },
146+
topicConfig: undefined,
147+
}),
148+
});
149+
150+
expect(ctx?.ctxPayload.InboundEventKind).toBe("room_event");
151+
await expect(ctx?.ackReactionPromise).resolves.toBe(true);
152+
expect(ctx?.statusReactionController).toBeNull();
153+
expect(createStatusReactionController).not.toHaveBeenCalled();
154+
expect(setMessageReaction).toHaveBeenCalledWith(-1001234567890, 12, [
155+
{ type: "emoji", emoji: "👀" },
156+
]);
157+
});
158+
111159
it("does not create status reactions when the ack gate blocks an unmentioned group message", async () => {
112160
const setMessageReaction = vi.fn(async () => undefined);
113161
const { createStatusReactionController } = createStatusReactionControllerStub();

extensions/telegram/src/bot-message-context.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,9 @@ export const buildTelegramMessageContext = async ({
530530
topicName,
531531
sessionRuntime,
532532
});
533-
const canShowStatusReaction = ctxPayload.InboundEventKind !== "room_event";
533+
const isRoomEvent = ctxPayload.InboundEventKind === "room_event";
534+
const canShowAckReaction = !isRoomEvent || ackReactionScope === "all";
535+
const canShowStatusReaction = !isRoomEvent;
534536
const ackReaction = resolveAckReaction(cfg, route.agentId, {
535537
channel: "telegram",
536538
accountId: account.accountId,
@@ -539,7 +541,7 @@ export const buildTelegramMessageContext = async ({
539541
ackReaction && isTelegramSupportedReactionEmoji(ackReaction) ? ackReaction : undefined;
540542
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
541543
const shouldSendAckReaction = Boolean(
542-
canShowStatusReaction &&
544+
canShowAckReaction &&
543545
ackReaction &&
544546
shouldAckReactionGate({
545547
scope: ackReactionScope,
@@ -554,7 +556,10 @@ export const buildTelegramMessageContext = async ({
554556
);
555557
const statusReactionsConfig = cfg.messages?.statusReactions;
556558
const statusReactionsEnabled =
557-
statusReactionsConfig?.enabled === true && Boolean(reactionApi) && shouldSendAckReaction;
559+
canShowStatusReaction &&
560+
statusReactionsConfig?.enabled === true &&
561+
Boolean(reactionApi) &&
562+
shouldSendAckReaction;
558563
const resolvedStatusReactionEmojis = statusReactionsEnabled
559564
? resolveTelegramStatusReactionEmojis({
560565
initialEmoji: ackReaction,

0 commit comments

Comments
 (0)