mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 01:42:59 +00:00
fix(whatsapp): restore ack emoji identity fallback (#86697)
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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 } }) =>
|
||||
|
||||
109
extensions/whatsapp/src/auto-reply/monitor/ack-emoji.test.ts
Normal file
109
extensions/whatsapp/src/auto-reply/monitor/ack-emoji.test.ts
Normal 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("👀");
|
||||
});
|
||||
});
|
||||
27
extensions/whatsapp/src/auto-reply/monitor/ack-emoji.ts
Normal file
27
extensions/whatsapp/src/auto-reply/monitor/ack-emoji.ts
Normal 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;
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user