fix(whatsapp): restore ack emoji identity fallback (#86697)

This commit is contained in:
Marcus Castro
2026-05-25 23:25:00 -03:00
committed by GitHub
parent f32273257c
commit 34d862d45d
8 changed files with 290 additions and 2 deletions

View File

@@ -522,6 +522,7 @@ Ack reactions are gated by `reactionLevel` — they are suppressed when `reactio
Behavior notes:
- sent immediately after inbound is accepted (pre-reply)
- if `ackReaction` is present without `emoji`, WhatsApp uses the routed agent's identity emoji, falling back to "👀"; omit `ackReaction` or set `emoji: ""` to send no ack reaction
- failures are logged but do not block normal reply delivery
- 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)
@@ -548,6 +549,7 @@ Set `messages.statusReactions.enabled: true` to let WhatsApp replace the ack rea
Behavior notes:
- `channels.whatsapp.ackReaction` still controls whether status reactions are eligible for direct messages and groups.
- The queued status reaction uses the same effective ack emoji as plain ack reactions.
- 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`.

View File

@@ -107,6 +107,13 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
resolveAgentIdentity: (
cfg: { agents?: { list?: Array<{ id: string; identity?: unknown }> } },
agentId: string,
) =>
cfg.agents?.list?.find(
(entry) => entry.id.trim().toLowerCase() === agentId.trim().toLowerCase(),
)?.identity,
resolveIdentityNamePrefix: (cfg: { messages?: { responsePrefix?: string } }, _agentId: string) =>
cfg.messages?.responsePrefix,
resolveMessagePrefix: (cfg: { messages?: { messagePrefix?: string } }) =>

View File

@@ -0,0 +1,109 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { resolveWhatsAppAckEmoji } from "./ack-emoji.js";
function createConfig(
ackReaction?: NonNullable<
NonNullable<NonNullable<OpenClawConfig["channels"]>["whatsapp"]>["ackReaction"]
>,
): OpenClawConfig {
const cfg: OpenClawConfig = {
agents: {
list: [{ id: "agent", identity: { emoji: "🔥" } }],
},
channels: {
whatsapp: {},
},
} as OpenClawConfig;
if (ackReaction !== undefined) {
cfg.channels!.whatsapp!.ackReaction = ackReaction;
}
return cfg;
}
describe("resolveWhatsAppAckEmoji", () => {
it("keeps missing ackReaction config disabled", () => {
expect(
resolveWhatsAppAckEmoji({
cfg: createConfig(),
agentId: "agent",
ackConfig: undefined,
}),
).toBe("");
});
it("uses the configured WhatsApp emoji when present", () => {
const cfg = createConfig({ emoji: " 👀 ", direct: true, group: "mentions" });
expect(
resolveWhatsAppAckEmoji({
cfg,
agentId: "agent",
ackConfig: cfg.channels?.whatsapp?.ackReaction,
}),
).toBe("👀");
});
it("keeps an explicit empty emoji disabled", () => {
const cfg = createConfig({ emoji: " ", direct: true, group: "mentions" });
expect(
resolveWhatsAppAckEmoji({
cfg,
agentId: "agent",
ackConfig: cfg.channels?.whatsapp?.ackReaction,
}),
).toBe("");
});
it("falls back to the routed agent identity emoji when the ack object has no emoji", () => {
const cfg = createConfig({ direct: true, group: "mentions" });
expect(
resolveWhatsAppAckEmoji({
cfg,
agentId: "agent",
ackConfig: cfg.channels?.whatsapp?.ackReaction,
}),
).toBe("🔥");
});
it("uses normalized agent ids for the identity fallback", () => {
const cfg: OpenClawConfig = {
agents: {
list: [{ id: "Agent", identity: { emoji: "🔥" } }],
},
channels: {
whatsapp: {
ackReaction: { direct: true, group: "mentions" },
},
},
} as OpenClawConfig;
expect(
resolveWhatsAppAckEmoji({
cfg,
agentId: "agent",
ackConfig: cfg.channels?.whatsapp?.ackReaction,
}),
).toBe("🔥");
});
it("uses the default ack emoji when configured without an emoji or agent identity", () => {
const cfg: OpenClawConfig = {
channels: {
whatsapp: {
ackReaction: { direct: true, group: "mentions" },
},
},
} as OpenClawConfig;
expect(
resolveWhatsAppAckEmoji({
cfg,
agentId: "agent",
ackConfig: cfg.channels?.whatsapp?.ackReaction,
}),
).toBe("👀");
});
});

View File

@@ -0,0 +1,27 @@
import { resolveAgentIdentity } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
const DEFAULT_WHATSAPP_ACK_REACTION = "👀";
type WhatsAppAckReactionConfig = NonNullable<
NonNullable<NonNullable<OpenClawConfig["channels"]>["whatsapp"]>["ackReaction"]
>;
export function resolveWhatsAppAckEmoji(params: {
cfg: OpenClawConfig;
agentId: string;
ackConfig: WhatsAppAckReactionConfig | undefined;
}): string {
if (!params.ackConfig) {
return "";
}
if (params.ackConfig.emoji !== undefined) {
return params.ackConfig.emoji.trim();
}
return resolveAgentIdentityEmoji(params.cfg, params.agentId) ?? DEFAULT_WHATSAPP_ACK_REACTION;
}
function resolveAgentIdentityEmoji(cfg: OpenClawConfig, agentId: string): string | undefined {
const emoji = resolveAgentIdentity(cfg, agentId)?.emoji?.trim();
return emoji || undefined;
}

View File

@@ -136,6 +136,39 @@ describe("maybeSendAckReaction", () => {
expectAckReactionSent("work", cfg);
});
it("uses the agent identity emoji when WhatsApp ackReaction has no emoji", async () => {
const cfg = {
agents: {
list: [{ id: "agent", identity: { emoji: "🔥" } }],
},
channels: {
whatsapp: {
reactionLevel: "ack",
ackReaction: {
direct: true,
group: "mentions",
},
},
},
} as OpenClawConfig;
const ackReaction = await runAckReaction({ cfg });
expect(ackReaction?.ackReactionValue).toBe("🔥");
await expect(ackReaction?.ackReactionPromise).resolves.toBe(true);
expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith(
"15551234567@s.whatsapp.net",
"msg-1",
"🔥",
{
verbose: false,
fromMe: false,
accountId: "default",
cfg,
},
);
});
it("returns a handle that removes the ack with an empty reaction", async () => {
const cfg = createConfig("ack");
const ackReaction = await runAckReaction({ cfg });

View File

@@ -10,6 +10,7 @@ import { resolveWhatsAppReactionLevel } from "../../reaction-level.js";
import { sendReactionWhatsApp } from "../../send.js";
import { formatError } from "../../session.js";
import type { WebInboundMsg } from "../types.js";
import { resolveWhatsAppAckEmoji } from "./ack-emoji.js";
import { resolveGroupActivationFor } from "./group-activation.js";
export async function maybeSendAckReaction(params: {
@@ -38,7 +39,11 @@ export async function maybeSendAckReaction(params: {
}
const ackConfig = params.cfg.channels?.whatsapp?.ackReaction;
const emoji = (ackConfig?.emoji ?? "").trim();
const emoji = resolveWhatsAppAckEmoji({
cfg: params.cfg,
agentId: params.agentId,
ackConfig,
});
const directEnabled = ackConfig?.direct ?? true;
const groupMode = ackConfig?.group ?? "mentions";
const conversationIdForCheck = params.msg.conversationId ?? params.msg.from;

View File

@@ -0,0 +1,100 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
import type { WebInboundMessage } from "../../inbound/types.js";
import { createWhatsAppStatusReactionController } from "./status-reaction.js";
const hoisted = vi.hoisted(() => ({
sendReactionWhatsApp: vi.fn(async () => undefined),
}));
vi.mock("../../send.js", () => ({
sendReactionWhatsApp: hoisted.sendReactionWhatsApp,
}));
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
return {
kind,
messageId: id,
keys: [{ id }],
providerAccepted: true,
};
}
function createMessage(overrides: Partial<WebInboundMessage> = {}): WebInboundMessage {
return {
id: "msg-1",
from: "15551234567",
conversationId: "15551234567",
to: "15559876543",
accountId: "default",
body: "hello",
chatType: "direct",
chatId: "15551234567@s.whatsapp.net",
sendComposing: async () => {},
reply: async () => acceptedSendResult("text", "r1"),
sendMedia: async () => acceptedSendResult("media", "m1"),
...overrides,
};
}
describe("createWhatsAppStatusReactionController", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses the agent identity emoji when WhatsApp ackReaction has no emoji", async () => {
const cfg = {
agents: {
list: [{ id: "agent", identity: { emoji: "🔥" } }],
},
messages: {
statusReactions: {
enabled: true,
timing: {
debounceMs: 1_000_000,
stallSoftMs: 1_000_000,
stallHardMs: 1_000_000,
doneHoldMs: 0,
errorHoldMs: 0,
},
},
},
channels: {
whatsapp: {
reactionLevel: "ack",
ackReaction: {
direct: true,
group: "mentions",
},
},
},
} as OpenClawConfig;
const controller = await createWhatsAppStatusReactionController({
cfg,
msg: createMessage(),
agentId: "agent",
sessionKey: "whatsapp:default:15551234567",
conversationId: "15551234567",
verbose: false,
accountId: "default",
});
void controller?.setQueued();
await vi.waitFor(() => {
expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith(
"15551234567@s.whatsapp.net",
"msg-1",
"🔥",
{
verbose: false,
fromMe: false,
accountId: "default",
cfg,
},
);
});
await controller?.clear();
});
});

View File

@@ -9,6 +9,7 @@ import { getSenderIdentity } from "../../identity.js";
import { resolveWhatsAppReactionLevel } from "../../reaction-level.js";
import { sendReactionWhatsApp } from "../../send.js";
import type { WebInboundMsg } from "../types.js";
import { resolveWhatsAppAckEmoji } from "./ack-emoji.js";
import { resolveGroupActivationFor } from "./group-activation.js";
export type { StatusReactionController };
@@ -44,7 +45,11 @@ export async function createWhatsAppStatusReactionController(
}
const ackConfig = params.cfg.channels?.whatsapp?.ackReaction;
const ackEmoji = (ackConfig?.emoji ?? "").trim();
const ackEmoji = resolveWhatsAppAckEmoji({
cfg: params.cfg,
agentId: params.agentId,
ackConfig,
});
if (!ackEmoji) {
return null;
}