fix: align WhatsApp status reactions

This commit is contained in:
Radek Sienkiewicz
2026-05-14 13:54:03 +02:00
parent cf9bb1f2fb
commit 25e0a7a9fd
11 changed files with 197 additions and 19 deletions

View File

@@ -482,6 +482,32 @@ Behavior notes:
- group mode `mentions` reacts on mention-triggered turns; group activation `always` acts as bypass for this check
- WhatsApp uses `channels.whatsapp.ackReaction` (legacy `messages.ackReaction` is not used here)
## Lifecycle status reactions
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.
```json5
{
messages: {
statusReactions: {
enabled: true,
emojis: {
deploy: "🛫",
build: "🏗️",
concierge: "💁",
},
},
},
}
```
Behavior notes:
- `channels.whatsapp.ackReaction` still controls whether status reactions are eligible for direct messages and groups.
- WhatsApp has one bot reaction slot per message, so lifecycle updates replace the current reaction in place.
- `messages.removeAckAfterReply: true` clears the final status reaction after the configured done/error hold.
- Tool emoji categories include `tool`, `coding`, `web`, `deploy`, `build`, and `concierge`.
## Multi-account and credentials
<AccordionGroup>

View File

@@ -1325,9 +1325,14 @@ Variables are case-insensitive. `{think}` is an alias for `{thinkingLevel}`.
- Resolution order: account → channel → `messages.ackReaction` → identity fallback.
- Scope: `group-mentions` (default), `group-all`, `direct`, `all`.
- `removeAckAfterReply`: removes ack after reply on reaction-capable channels such as Slack, Discord, Telegram, WhatsApp, and iMessage.
- `messages.statusReactions.enabled`: enables lifecycle status reactions on Slack, Discord, and Telegram.
- `messages.statusReactions.enabled`: enables lifecycle status reactions on Slack, Discord, Telegram, and WhatsApp.
On Slack and Discord, unset keeps status reactions enabled when ack reactions are active.
On Telegram, set it explicitly to `true` to enable lifecycle status reactions.
On Telegram and WhatsApp, set it explicitly to `true` to enable lifecycle status reactions.
- `messages.statusReactions.emojis`: overrides lifecycle emoji keys:
`queued`, `thinking`, `compacting`, `tool`, `coding`, `web`, `deploy`, `build`,
`concierge`, `done`, `error`, `stallSoft`, and `stallHard`.
Telegram only allows a fixed reaction set, so unsupported configured emoji fall back
to the nearest supported status variant for that chat.
### Inbound debounce

View File

@@ -50,6 +50,7 @@ tool with the `react` action. Reaction behavior varies by channel and transport.
<Accordion title="WhatsApp">
- Empty `emoji` removes the bot reaction.
- `remove: true` maps to empty emoji internally (still requires `emoji` in the tool call).
- WhatsApp has one bot reaction slot per message; status reaction updates replace that slot rather than stacking multiple emoji.
</Accordion>

View File

@@ -757,9 +757,9 @@ describe("monitorSlackProvider tool results", () => {
expect(sendMock).not.toHaveBeenCalled();
expectReactionFlow({
startsWith: ["eyes", "scream"],
includes: "scream",
endsWith: "scream",
startsWith: ["eyes", "x"],
includes: "x",
endsWith: "x",
});
});

View File

@@ -5,6 +5,20 @@ const transcribeFirstAudioMock = vi.fn();
const maybeSendAckReactionMock = vi.fn();
const processMessageMock = vi.fn();
const maybeBroadcastMessageMock = vi.fn();
const createStatusReactionControllerMock = vi.fn();
const statusReactionController = {
setQueued: vi.fn(async () => {
events.push("status-queued");
}),
setThinking: vi.fn(async () => undefined),
setTool: vi.fn(async () => undefined),
setCompacting: vi.fn(async () => undefined),
cancelPending: vi.fn(),
setDone: vi.fn(async () => undefined),
setError: vi.fn(async () => undefined),
clear: vi.fn(async () => undefined),
restoreInitial: vi.fn(async () => undefined),
};
const ackReactionHandle = {
ackReactionPromise: Promise.resolve(true),
ackReactionValue: "👀",
@@ -28,6 +42,11 @@ vi.mock("./broadcast.js", () => ({
maybeBroadcastMessage: (...args: unknown[]) => maybeBroadcastMessageMock(...args),
}));
vi.mock("./status-reaction.js", () => ({
createWhatsAppStatusReactionController: (...args: unknown[]) =>
createStatusReactionControllerMock(...args),
}));
vi.mock("./group-gating.js", () => ({
applyGroupGating: (...args: unknown[]) => applyGroupGatingMock(...args),
}));
@@ -140,6 +159,9 @@ describe("createWebOnMessageHandler audio preflight", () => {
});
processMessageMock.mockReset();
processMessageMock.mockResolvedValue(true);
createStatusReactionControllerMock.mockReset();
createStatusReactionControllerMock.mockResolvedValue(statusReactionController);
Object.values(statusReactionController).forEach((mock) => mock.mockClear());
applyGroupGatingMock.mockReset();
applyGroupGatingMock.mockResolvedValue({ shouldProcess: true });
});
@@ -182,6 +204,47 @@ describe("createWebOnMessageHandler audio preflight", () => {
expect(processParams.ackReaction).toBe(ackReactionHandle);
});
it("sends queued status reaction before audio preflight when status reactions are enabled", async () => {
const handler = createWebOnMessageHandler({
cfg: {
messages: { statusReactions: { enabled: true } },
channels: {
whatsapp: {
ackReaction: { enabled: true },
},
},
} as never,
verbose: false,
connectionId: "conn-1",
maxMediaBytes: 1024 * 1024,
groupHistoryLimit: 20,
groupHistories: new Map(),
groupMemberNames: new Map(),
echoTracker: makeEchoTracker() as never,
backgroundTasks: new Set(),
replyResolver: vi.fn() as never,
replyLogger: {
info: () => {},
warn: () => {},
debug: () => {},
error: () => {},
} as never,
baseMentionConfig: {} as never,
account: { authDir: "/tmp/auth", accountId: "default" },
});
await handler(makeAudioMsg());
expect(events).toEqual(["status-queued", "stt"]);
expect(maybeSendAckReactionMock).not.toHaveBeenCalled();
expect(createStatusReactionControllerMock).toHaveBeenCalledTimes(1);
expect(processMessageMock).toHaveBeenCalledTimes(1);
const processParams = mockObjectArg(processMessageMock, "processMessage");
expect(processParams.preflightAudioTranscript).toBe("transcribed voice note");
expect(processParams.statusReactionController).toBe(statusReactionController);
expect(processParams.ackAlreadySent).toBeUndefined();
});
it("skips early DM ack/preflight when access-control was not explicitly passed through", async () => {
const handler = createWebOnMessageHandler({
cfg: {

View File

@@ -20,6 +20,10 @@ import { applyGroupGating } from "./group-gating.js";
import { updateLastRouteInBackground } from "./last-route.js";
import { resolvePeerId } from "./peer.js";
import { processMessage } from "./process-message.js";
import {
createWhatsAppStatusReactionController,
type StatusReactionController,
} from "./status-reaction.js";
export function createWebOnMessageHandler(params: {
cfg: OpenClawConfig;
@@ -48,6 +52,7 @@ export function createWebOnMessageHandler(params: {
preflightAudioTranscript?: string | null;
ackAlreadySent?: boolean;
ackReaction?: AckReactionHandle | null;
statusReactionController?: StatusReactionController | null;
},
) => {
const processParams: Parameters<typeof processMessage>[0] = {
@@ -83,6 +88,9 @@ export function createWebOnMessageHandler(params: {
if (opts?.ackReaction !== undefined) {
processParams.ackReaction = opts.ackReaction;
}
if (opts?.statusReactionController !== undefined) {
processParams.statusReactionController = opts.statusReactionController;
}
return processMessage(processParams);
};
@@ -141,6 +149,7 @@ export function createWebOnMessageHandler(params: {
const canRunEarlyAudioPreflight = msg.chatType === "group" || msg.accessControlPassed === true;
let ackAlreadySent = false;
let ackReaction: AckReactionHandle | null = null;
let statusReactionController: StatusReactionController | null = null;
const runAudioPreflightOnce = async () => {
if (
preflightAudioTranscript !== undefined ||
@@ -150,7 +159,20 @@ export function createWebOnMessageHandler(params: {
) {
return;
}
if (cfg.messages?.statusReactions?.enabled !== true) {
if (cfg.messages?.statusReactions?.enabled === true) {
statusReactionController = await createWhatsAppStatusReactionController({
cfg,
msg,
agentId: route.agentId,
sessionKey: route.sessionKey,
conversationId,
verbose: params.verbose,
accountId: route.accountId,
});
if (statusReactionController) {
await statusReactionController.setQueued();
}
} else {
ackReaction = await maybeSendAckReaction({
cfg,
msg,
@@ -294,6 +316,7 @@ export function createWebOnMessageHandler(params: {
// per-agent checks during broadcast fan-out.
...(ackAlreadySent && msg.chatType !== "group" ? { ackAlreadySent: true } : {}),
...(ackReaction && msg.chatType !== "group" ? { ackReaction } : {}),
...(statusReactionController && msg.chatType !== "group" ? { ackAlreadySent: true } : {}),
processMessage: (m, r, k, opts) => processForRoute(cfg, m, r, k, opts),
})
) {
@@ -304,6 +327,7 @@ export function createWebOnMessageHandler(params: {
...(preflightAudioTranscript !== undefined ? { preflightAudioTranscript } : {}),
...(ackAlreadySent ? { ackAlreadySent: true } : {}),
...(ackReaction ? { ackReaction } : {}),
...(statusReactionController ? { statusReactionController } : {}),
});
};
}

View File

@@ -32,7 +32,6 @@ import { whatsappInboundLog } from "../loggers.js";
import type { WebInboundMsg } from "../types.js";
import { elide } from "../util.js";
import { maybeSendAckReaction } from "./ack-reaction.js";
import { createWhatsAppStatusReactionController } from "./status-reaction.js";
import {
resolveVisibleWhatsAppGroupHistory,
resolveVisibleWhatsAppReplyContext,
@@ -64,6 +63,10 @@ import {
type LoadConfigFn,
type resolveAgentRoute,
} from "./runtime-api.js";
import {
createWhatsAppStatusReactionController,
type StatusReactionController,
} from "./status-reaction.js";
const WHATSAPP_MESSAGE_RECEIVED_HOOK_LIMITS = {
maxConcurrency: 8,
@@ -199,6 +202,7 @@ export async function processMessage(params: {
suppressGroupHistoryClear?: boolean;
ackAlreadySent?: boolean;
ackReaction?: AckReactionHandle | null;
statusReactionController?: StatusReactionController | null;
/** Pre-computed audio transcript from a caller-level preflight, used to avoid
* re-transcribing the same voice note once per broadcast agent.
* - string → transcript obtained; use it directly, skip internal STT
@@ -331,7 +335,8 @@ export async function processMessage(params: {
// signaling (queued → thinking → tool → done/error). The plain ackReaction is
// skipped so the same message slot isn't used for two competing systems.
const statusReactionController =
params.cfg.messages?.statusReactions?.enabled === true && !params.ackAlreadySent
params.statusReactionController ??
(params.cfg.messages?.statusReactions?.enabled === true && !params.ackAlreadySent
? await createWhatsAppStatusReactionController({
cfg: params.cfg,
msg: params.msg,
@@ -341,9 +346,9 @@ export async function processMessage(params: {
verbose: params.verbose,
accountId: account.accountId,
})
: null;
: null);
if (statusReactionController) {
if (statusReactionController && !params.statusReactionController) {
void statusReactionController.setQueued();
}

View File

@@ -94,7 +94,7 @@ export async function createWhatsAppStatusReactionController(
setReaction: async (emoji: string) => {
await sendReactionWhatsApp(chatId, msgId, emoji, reactionOptions);
},
removeReaction: async (_emoji: string) => {
clearReaction: async () => {
await sendReactionWhatsApp(chatId, msgId, "", reactionOptions);
},
},
@@ -102,9 +102,7 @@ export async function createWhatsAppStatusReactionController(
emojis: statusReactionsConfig.emojis,
timing: statusReactionsConfig.timing,
onError: (err) => {
logVerbose(
`WhatsApp status-reaction error for chat ${chatId}/${msgId}: ${String(err)}`,
);
logVerbose(`WhatsApp status-reaction error for chat ${chatId}/${msgId}: ${String(err)}`);
},
});
}

View File

@@ -56,6 +56,24 @@ const createSetOnlyController = () => {
return { calls, controller };
};
const createSingleSlotController = () => {
const calls: { method: string; emoji: string }[] = [];
const adapter: StatusReactionAdapter = {
setReaction: vi.fn(async (emoji: string) => {
calls.push({ method: "set", emoji });
}),
clearReaction: vi.fn(async () => {
calls.push({ method: "clear", emoji: "" });
}),
};
const controller = createStatusReactionController({
enabled: true,
adapter,
initialEmoji: "👀",
});
return { calls, controller };
};
function expectSetEmojiCall(calls: Array<{ method: string; emoji: string }>, emoji: string) {
expect(collectEmojisForMethod(calls, "set")).toContain(emoji);
}
@@ -408,6 +426,30 @@ describe("createStatusReactionController", () => {
]);
});
it("uses clearReaction only for explicit clear on single-slot adapters", async () => {
const { calls, controller } = createSingleSlotController();
void controller.setQueued();
await vi.runAllTimersAsync();
void controller.setThinking();
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
await controller.setDone();
await controller.restoreInitial();
expect(calls).toEqual([
{ method: "set", emoji: "👀" },
{ method: "set", emoji: DEFAULT_EMOJIS.stallSoft },
{ method: "set", emoji: DEFAULT_EMOJIS.stallHard },
{ method: "set", emoji: DEFAULT_EMOJIS.thinking },
{ method: "set", emoji: DEFAULT_EMOJIS.done },
{ method: "set", emoji: "👀" },
]);
await controller.clear();
expect(calls.at(-1)).toEqual({ method: "clear", emoji: "" });
});
it("should not re-add an already active reaction when returning to it", async () => {
const { calls, controller } = createEnabledController();
@@ -599,6 +641,9 @@ describe("constants", () => {
"tool",
"coding",
"web",
"deploy",
"build",
"concierge",
"done",
"error",
"stallSoft",

View File

@@ -14,6 +14,8 @@ import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
export type StatusReactionAdapter = {
/** Set/replace the current reaction emoji. */
setReaction: (emoji: string) => Promise<void>;
/** Clear all status reactions for single-slot platforms such as WhatsApp. */
clearReaction?: () => Promise<void>;
/** Remove a specific reaction emoji (optional — needed for Discord-style platforms). */
removeReaction?: (emoji: string) => Promise<void>;
};
@@ -439,11 +441,20 @@ export function createStatusReactionController(params: {
finished = true;
await enqueue(async () => {
if (adapter.removeReaction) {
if (adapter.clearReaction) {
try {
await adapter.clearReaction();
} catch (err) {
if (onError) {
onError(err);
}
} finally {
activeEmojis.clear();
}
} else if (adapter.removeReaction) {
await removeActiveEmojis();
} else {
// For platforms without removeReaction, set empty or just skip
// (Telegram handles this atomically on the next setReaction)
// Telegram handles this atomically on the next setReaction.
}
currentEmoji = "";
pendingEmoji = "";

View File

@@ -1848,9 +1848,9 @@ export const FIELD_HELP: Record<string, string> = {
"messages.statusReactions":
"Lifecycle status reactions that update the emoji on the trigger message as the agent progresses (queued → thinking → tool → done/error).",
"messages.statusReactions.enabled":
"Enable lifecycle status reactions on supported channels. Slack and Discord treat unset as enabled when ack reactions are active; Telegram requires this to be true before lifecycle reactions are used.",
"Enable lifecycle status reactions on supported channels. Slack and Discord treat unset as enabled when ack reactions are active; Telegram and WhatsApp require this to be true before lifecycle reactions are used.",
"messages.statusReactions.emojis":
"Override default status reaction emojis. Keys: thinking, compacting, tool, coding, web, deploy, build, concierge, done, error, stallSoft, stallHard. Must be valid Telegram reaction emojis.",
"Override default status reaction emojis. Keys: queued, thinking, compacting, tool, coding, web, deploy, build, concierge, done, error, stallSoft, stallHard. Telegram chooses the first supported fallback when a configured emoji is not available in the chat.",
"messages.statusReactions.timing":
"Override default timing. Keys: debounceMs (700), stallSoftMs (25000), stallHardMs (60000), doneHoldMs (1500), errorHoldMs (2500).",
"messages.inbound.debounceMs":