mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix: align WhatsApp status reactions
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user