Skip to content

Commit 83b8289

Browse files
feat: WhatsApp status reactions, new emoji categories, self-explanatory defaults (#59077) (#80612)
Merged via squash. Prepared head SHA: 25e0a7a Co-authored-by: gado-ships-it <276509604+gado-ships-it@users.noreply.github.com> Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com> Reviewed-by: @velvet-shark
1 parent 74dae60 commit 83b8289

18 files changed

Lines changed: 518 additions & 38 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
1515
- Control UI/WebChat: add a persisted auto-scroll mode selector so users can keep the current near-bottom behavior, always follow streaming output, or turn automatic streaming scroll off and use the New messages button manually. Fixes #7648 and #81287. Thanks @BunsDev.
1616
- ACP: add `acp.fallbacks` so ACP turns can try configured backup runtime backends when the primary backend is unavailable before any output is emitted. (#69542) Thanks @kaseonedge.
1717
- Gateway/startup: add owner-level startup trace attribution for auth, plugin loading, lookup counts, and plugin sidecar services. (#81738) Thanks @samzong.
18+
- Channels/status reactions: wire `StatusReactionController` into WhatsApp message turns (queued → thinking → tool → done/error lifecycle, on par with Telegram and Discord), add `deploy`/`build`/`concierge` emoji categories with tool-token routing, and replace the status reaction defaults with self-explanatory emoji (🧠 thinking, 🛠️ tool, 💻 coding, 🌐 web, ⏳ stallSoft, ⚠️ stallHard, ✅ done, ❌ error, 🗜️ compacting) so stall and lifecycle reactions read as status indicators instead of emotional commentary. Fixes #59077. (#80612) Thanks @gado-ships-it.
1819

1920
### Fixes
2021

docs/channels/whatsapp.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,32 @@ Behavior notes:
482482
- group mode `mentions` reacts on mention-triggered turns; group activation `always` acts as bypass for this check
483483
- WhatsApp uses `channels.whatsapp.ackReaction` (legacy `messages.ackReaction` is not used here)
484484

485+
## Lifecycle status reactions
486+
487+
Set `messages.statusReactions.enabled: true` to let WhatsApp replace the ack reaction during a turn instead of leaving a static receipt emoji. When enabled, OpenClaw uses the same inbound message reaction slot for lifecycle states such as queued, thinking, tool activity, compaction, done, and error.
488+
489+
```json5
490+
{
491+
messages: {
492+
statusReactions: {
493+
enabled: true,
494+
emojis: {
495+
deploy: "🛫",
496+
build: "🏗️",
497+
concierge: "💁",
498+
},
499+
},
500+
},
501+
}
502+
```
503+
504+
Behavior notes:
505+
506+
- `channels.whatsapp.ackReaction` still controls whether status reactions are eligible for direct messages and groups.
507+
- WhatsApp has one bot reaction slot per message, so lifecycle updates replace the current reaction in place.
508+
- `messages.removeAckAfterReply: true` clears the final status reaction after the configured done/error hold.
509+
- Tool emoji categories include `tool`, `coding`, `web`, `deploy`, `build`, and `concierge`.
510+
485511
## Multi-account and credentials
486512

487513
<AccordionGroup>

docs/gateway/config-agents.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1325,9 +1325,14 @@ Variables are case-insensitive. `{think}` is an alias for `{thinkingLevel}`.
13251325
- Resolution order: account → channel → `messages.ackReaction` → identity fallback.
13261326
- Scope: `group-mentions` (default), `group-all`, `direct`, `all`.
13271327
- `removeAckAfterReply`: removes ack after reply on reaction-capable channels such as Slack, Discord, Telegram, WhatsApp, and iMessage.
1328-
- `messages.statusReactions.enabled`: enables lifecycle status reactions on Slack, Discord, and Telegram.
1328+
- `messages.statusReactions.enabled`: enables lifecycle status reactions on Slack, Discord, Telegram, and WhatsApp.
13291329
On Slack and Discord, unset keeps status reactions enabled when ack reactions are active.
1330-
On Telegram, set it explicitly to `true` to enable lifecycle status reactions.
1330+
On Telegram and WhatsApp, set it explicitly to `true` to enable lifecycle status reactions.
1331+
- `messages.statusReactions.emojis`: overrides lifecycle emoji keys:
1332+
`queued`, `thinking`, `compacting`, `tool`, `coding`, `web`, `deploy`, `build`,
1333+
`concierge`, `done`, `error`, `stallSoft`, and `stallHard`.
1334+
Telegram only allows a fixed reaction set, so unsupported configured emoji fall back
1335+
to the nearest supported status variant for that chat.
13311336

13321337
### Inbound debounce
13331338

docs/tools/reactions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ tool with the `react` action. Reaction behavior varies by channel and transport.
5050
<Accordion title="WhatsApp">
5151
- Empty `emoji` removes the bot reaction.
5252
- `remove: true` maps to empty emoji internally (still requires `emoji` in the tool call).
53+
- WhatsApp has one bot reaction slot per message; status reaction updates replace that slot rather than stacking multiple emoji.
5354

5455
</Accordion>
5556

extensions/slack/src/monitor.tool-result.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -757,9 +757,9 @@ describe("monitorSlackProvider tool results", () => {
757757

758758
expect(sendMock).not.toHaveBeenCalled();
759759
expectReactionFlow({
760-
startsWith: ["eyes", "scream"],
761-
includes: "scream",
762-
endsWith: "scream",
760+
startsWith: ["eyes", "x"],
761+
includes: "x",
762+
endsWith: "x",
763763
});
764764
});
765765

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ const UNICODE_TO_SLACK: Record<string, string> = {
104104
"⏳": "hourglass_flowing_sand",
105105
"⚠️": "warning",
106106
"✍": "writing_hand",
107+
"🗜️": "compression",
108+
"🗜": "compression",
107109
"🧠": "brain",
108110
"🛠️": "hammer_and_wrench",
109111
"💻": "computer",

extensions/telegram/src/status-reaction-variants.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ const TELEGRAM_STATUS_REACTION_VARIANTS: Record<StatusReactionEmojiKey, string[]
9494
tool: ["🔥", "⚡", "👍"],
9595
coding: ["👨‍💻", "🔥", "⚡"],
9696
web: ["⚡", "🔥", "👍"],
97+
deploy: ["🔥", "⚡", "👍"],
98+
build: ["🔥", "👨‍💻", "⚡"],
99+
concierge: ["👀", "🔥", "⚡"],
97100
done: ["👍", "🎉", "💯"],
98101
error: ["😱", "😨", "🤯"],
99102
stallSoft: ["🥱", "😴", "🤔"],
@@ -107,6 +110,9 @@ const STATUS_REACTION_EMOJI_KEYS: StatusReactionEmojiKey[] = [
107110
"tool",
108111
"coding",
109112
"web",
113+
"deploy",
114+
"build",
115+
"concierge",
110116
"done",
111117
"error",
112118
"stallSoft",
@@ -130,6 +136,9 @@ export function resolveTelegramStatusReactionEmojis(params: {
130136
tool: normalizeOptionalString(overrides?.tool) ?? DEFAULT_EMOJIS.tool,
131137
coding: normalizeOptionalString(overrides?.coding) ?? DEFAULT_EMOJIS.coding,
132138
web: normalizeOptionalString(overrides?.web) ?? DEFAULT_EMOJIS.web,
139+
deploy: normalizeOptionalString(overrides?.deploy) ?? DEFAULT_EMOJIS.deploy,
140+
build: normalizeOptionalString(overrides?.build) ?? DEFAULT_EMOJIS.build,
141+
concierge: normalizeOptionalString(overrides?.concierge) ?? DEFAULT_EMOJIS.concierge,
133142
done: normalizeOptionalString(overrides?.done) ?? DEFAULT_EMOJIS.done,
134143
error: normalizeOptionalString(overrides?.error) ?? DEFAULT_EMOJIS.error,
135144
stallSoft: normalizeOptionalString(overrides?.stallSoft) ?? DEFAULT_EMOJIS.stallSoft,

extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { deliverInboundReplyWithMessageSendContext } from "openclaw/plugin-sdk/channel-message";
2+
import { DEFAULT_TIMING, type StatusReactionController } from "openclaw/plugin-sdk/channel-feedback";
23
import { hasVisibleInboundReplyDispatch } from "openclaw/plugin-sdk/inbound-reply-dispatch";
34
import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
45
import {
@@ -399,7 +400,14 @@ export async function dispatchWhatsAppBufferedReply(params: {
399400
replyResolver: typeof getReplyFromConfig;
400401
route: ReturnType<typeof resolveAgentRoute>;
401402
shouldClearGroupHistory: boolean;
403+
statusReactionController?: StatusReactionController | null;
402404
}) {
405+
const statusReactionController = params.statusReactionController ?? null;
406+
const statusReactionTiming = {
407+
...DEFAULT_TIMING,
408+
...params.cfg.messages?.statusReactions?.timing,
409+
};
410+
const removeAckAfterReply = params.cfg.messages?.removeAckAfterReply ?? false;
403411
const textLimit = params.maxMediaTextChunkLimit ?? resolveTextChunkLimit(params.cfg, "whatsapp");
404412
const chunkMode = resolveChunkMode(params.cfg, "whatsapp", params.route.accountId);
405413
const tableMode = resolveMarkdownTableMode({
@@ -484,6 +492,10 @@ export async function dispatchWhatsAppBufferedReply(params: {
484492
},
485493
});
486494

495+
if (statusReactionController) {
496+
void statusReactionController.setThinking();
497+
}
498+
487499
const { queuedFinal, counts } = await dispatchReplyWithBufferedBlockDispatcher({
488500
ctx: params.context,
489501
cfg: params.cfg,
@@ -563,6 +575,17 @@ export async function dispatchWhatsAppBufferedReply(params: {
563575
await deliverNormalizedPayload(normalizedDeliveryPayload, info);
564576
},
565577
onReplyStart: params.msg.sendComposing,
578+
...(statusReactionController
579+
? {
580+
onCompactionStart: async () => {
581+
await statusReactionController.setCompacting();
582+
},
583+
onCompactionEnd: async () => {
584+
statusReactionController.cancelPending();
585+
await statusReactionController.setThinking();
586+
},
587+
}
588+
: {}),
566589
onError: (err, info) => {
567590
logWhatsAppReplyDeliveryError({
568591
err,
@@ -578,22 +601,84 @@ export async function dispatchWhatsAppBufferedReply(params: {
578601
disableBlockStreaming,
579602
...(sourceReplyDeliveryMode ? { sourceReplyDeliveryMode } : {}),
580603
onModelSelected: params.onModelSelected,
604+
...(statusReactionController
605+
? {
606+
onToolStart: async (payload: { name?: string }) => {
607+
const toolName = payload.name?.trim();
608+
if (toolName) {
609+
await statusReactionController.setTool(toolName);
610+
}
611+
},
612+
}
613+
: {}),
581614
},
582615
});
583616
logWhatsAppMediaOnlyFlushResult(await mediaOnlyCoalescer.flushAll());
584617

585618
const didQueueVisibleReply = hasVisibleInboundReplyDispatch({ queuedFinal, counts });
586619
if (!didQueueVisibleReply) {
620+
if (statusReactionController) {
621+
void finalizeWhatsAppStatusReaction({
622+
controller: statusReactionController,
623+
outcome: "error",
624+
hasFinalResponse: false,
625+
removeAckAfterReply,
626+
timing: statusReactionTiming,
627+
});
628+
}
587629
if (params.shouldClearGroupHistory) {
588630
params.groupHistories.set(params.groupHistoryKey, []);
589631
}
590632
logVerbose("Skipping auto-reply: silent token or no text/media returned from resolver");
591633
return false;
592634
}
593635

636+
if (statusReactionController) {
637+
void finalizeWhatsAppStatusReaction({
638+
controller: statusReactionController,
639+
outcome: didSendReply ? "done" : "error",
640+
hasFinalResponse: didSendReply,
641+
removeAckAfterReply,
642+
timing: statusReactionTiming,
643+
});
644+
}
645+
594646
if (params.shouldClearGroupHistory) {
595647
params.groupHistories.set(params.groupHistoryKey, []);
596648
}
597649

598650
return didSendReply;
599651
}
652+
653+
async function finalizeWhatsAppStatusReaction(params: {
654+
controller: StatusReactionController;
655+
outcome: "done" | "error";
656+
hasFinalResponse: boolean;
657+
removeAckAfterReply: boolean;
658+
timing: typeof DEFAULT_TIMING;
659+
}): Promise<void> {
660+
if (params.outcome === "done") {
661+
await params.controller.setDone();
662+
if (params.removeAckAfterReply) {
663+
await new Promise<void>((resolve) => setTimeout(resolve, params.timing.doneHoldMs));
664+
await params.controller.clear();
665+
} else {
666+
await params.controller.restoreInitial();
667+
}
668+
return;
669+
}
670+
await params.controller.setError();
671+
if (params.hasFinalResponse) {
672+
if (params.removeAckAfterReply) {
673+
await new Promise<void>((resolve) => setTimeout(resolve, params.timing.errorHoldMs));
674+
await params.controller.clear();
675+
} else {
676+
await params.controller.restoreInitial();
677+
}
678+
return;
679+
}
680+
if (params.removeAckAfterReply) {
681+
await new Promise<void>((resolve) => setTimeout(resolve, params.timing.errorHoldMs));
682+
}
683+
await params.controller.restoreInitial();
684+
}

extensions/whatsapp/src/auto-reply/monitor/on-message.audio-preflight.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ const transcribeFirstAudioMock = vi.fn();
55
const maybeSendAckReactionMock = vi.fn();
66
const processMessageMock = vi.fn();
77
const maybeBroadcastMessageMock = vi.fn();
8+
const createStatusReactionControllerMock = vi.fn();
9+
const statusReactionController = {
10+
setQueued: vi.fn(async () => {
11+
events.push("status-queued");
12+
}),
13+
setThinking: vi.fn(async () => undefined),
14+
setTool: vi.fn(async () => undefined),
15+
setCompacting: vi.fn(async () => undefined),
16+
cancelPending: vi.fn(),
17+
setDone: vi.fn(async () => undefined),
18+
setError: vi.fn(async () => undefined),
19+
clear: vi.fn(async () => undefined),
20+
restoreInitial: vi.fn(async () => undefined),
21+
};
822
const ackReactionHandle = {
923
ackReactionPromise: Promise.resolve(true),
1024
ackReactionValue: "👀",
@@ -28,6 +42,11 @@ vi.mock("./broadcast.js", () => ({
2842
maybeBroadcastMessage: (...args: unknown[]) => maybeBroadcastMessageMock(...args),
2943
}));
3044

45+
vi.mock("./status-reaction.js", () => ({
46+
createWhatsAppStatusReactionController: (...args: unknown[]) =>
47+
createStatusReactionControllerMock(...args),
48+
}));
49+
3150
vi.mock("./group-gating.js", () => ({
3251
applyGroupGating: (...args: unknown[]) => applyGroupGatingMock(...args),
3352
}));
@@ -140,6 +159,9 @@ describe("createWebOnMessageHandler audio preflight", () => {
140159
});
141160
processMessageMock.mockReset();
142161
processMessageMock.mockResolvedValue(true);
162+
createStatusReactionControllerMock.mockReset();
163+
createStatusReactionControllerMock.mockResolvedValue(statusReactionController);
164+
Object.values(statusReactionController).forEach((mock) => mock.mockClear());
143165
applyGroupGatingMock.mockReset();
144166
applyGroupGatingMock.mockResolvedValue({ shouldProcess: true });
145167
});
@@ -182,6 +204,47 @@ describe("createWebOnMessageHandler audio preflight", () => {
182204
expect(processParams.ackReaction).toBe(ackReactionHandle);
183205
});
184206

207+
it("sends queued status reaction before audio preflight when status reactions are enabled", async () => {
208+
const handler = createWebOnMessageHandler({
209+
cfg: {
210+
messages: { statusReactions: { enabled: true } },
211+
channels: {
212+
whatsapp: {
213+
ackReaction: { enabled: true },
214+
},
215+
},
216+
} as never,
217+
verbose: false,
218+
connectionId: "conn-1",
219+
maxMediaBytes: 1024 * 1024,
220+
groupHistoryLimit: 20,
221+
groupHistories: new Map(),
222+
groupMemberNames: new Map(),
223+
echoTracker: makeEchoTracker() as never,
224+
backgroundTasks: new Set(),
225+
replyResolver: vi.fn() as never,
226+
replyLogger: {
227+
info: () => {},
228+
warn: () => {},
229+
debug: () => {},
230+
error: () => {},
231+
} as never,
232+
baseMentionConfig: {} as never,
233+
account: { authDir: "/tmp/auth", accountId: "default" },
234+
});
235+
236+
await handler(makeAudioMsg());
237+
238+
expect(events).toEqual(["status-queued", "stt"]);
239+
expect(maybeSendAckReactionMock).not.toHaveBeenCalled();
240+
expect(createStatusReactionControllerMock).toHaveBeenCalledTimes(1);
241+
expect(processMessageMock).toHaveBeenCalledTimes(1);
242+
const processParams = mockObjectArg(processMessageMock, "processMessage");
243+
expect(processParams.preflightAudioTranscript).toBe("transcribed voice note");
244+
expect(processParams.statusReactionController).toBe(statusReactionController);
245+
expect(processParams.ackAlreadySent).toBeUndefined();
246+
});
247+
185248
it("skips early DM ack/preflight when access-control was not explicitly passed through", async () => {
186249
const handler = createWebOnMessageHandler({
187250
cfg: {

0 commit comments

Comments
 (0)