feat(whatsapp): redesign ack-reaction as whatsapp-specific feature
- Move config from messages.ackReaction to whatsapp.ackReaction
- New structure: {emoji, direct, group} with granular control
- Support per-account overrides in whatsapp.accounts.*.ackReaction
- Add Zod schema validation for new config
- Maintain backward compatibility with old messages.ackReaction format
- Update tests to new config structure (14 tests, all passing)
- Add comprehensive documentation in docs/providers/whatsapp.md
- Timing: reactions sent immediately upon message receipt (before bot reply)
Breaking changes:
- Config moved from messages.ackReaction to whatsapp.ackReaction
- Scope values changed: 'all'/'direct'/'group-all'/'group-mentions'
→ direct: boolean + group: 'always'/'mentions'/'never'
- Old config still supported via fallback for smooth migration
This commit is contained in:
committed by
Peter Steinberger
parent
d38b232724
commit
2daead27cf
@@ -159,6 +159,54 @@ Behavior:
|
|||||||
- WhatsApp Web sends standard messages (no quoted reply threading in the current gateway).
|
- WhatsApp Web sends standard messages (no quoted reply threading in the current gateway).
|
||||||
- Reply tags are ignored on this provider.
|
- Reply tags are ignored on this provider.
|
||||||
|
|
||||||
|
## Acknowledgment reactions (auto-react on receipt)
|
||||||
|
|
||||||
|
WhatsApp can automatically send emoji reactions to incoming messages immediately upon receipt, before the bot generates a reply. This provides instant feedback to users that their message was received.
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"whatsapp": {
|
||||||
|
"ackReaction": {
|
||||||
|
"emoji": "👀",
|
||||||
|
"direct": true,
|
||||||
|
"group": "mentions"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `emoji` (string): Emoji to use for acknowledgment (e.g., "👀", "✅", "📨"). Empty or omitted = feature disabled.
|
||||||
|
- `direct` (boolean, default: `true`): Send reactions in direct/DM chats.
|
||||||
|
- `group` (string, default: `"mentions"`): Group chat behavior:
|
||||||
|
- `"always"`: React to all group messages (even without @mention)
|
||||||
|
- `"mentions"`: React only when bot is @mentioned
|
||||||
|
- `"never"`: Never react in groups
|
||||||
|
|
||||||
|
**Per-account override:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"whatsapp": {
|
||||||
|
"accounts": {
|
||||||
|
"work": {
|
||||||
|
"ackReaction": {
|
||||||
|
"emoji": "✅",
|
||||||
|
"direct": false,
|
||||||
|
"group": "always"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior notes:**
|
||||||
|
- Reactions are sent **immediately** upon message receipt, before typing indicators or bot replies.
|
||||||
|
- In groups with `requireMention: false` (activation: always), `group: "mentions"` will react to all messages (not just @mentions).
|
||||||
|
- Fire-and-forget: reaction failures are logged but don't prevent the bot from replying.
|
||||||
|
- Participant JID is automatically included for group reactions.
|
||||||
|
|
||||||
## Agent tool (reactions)
|
## Agent tool (reactions)
|
||||||
- Tool: `whatsapp` with `react` action (`chatJid`, `messageId`, `emoji`, optional `remove`).
|
- Tool: `whatsapp` with `react` action (`chatJid`, `messageId`, `emoji`, optional `remove`).
|
||||||
- Optional: `participant` (group sender), `fromMe` (reacting to your own message), `accountId` (multi-account).
|
- Optional: `participant` (group sender), `fromMe` (reacting to your own message), `accountId` (multi-account).
|
||||||
@@ -205,8 +253,10 @@ Behavior:
|
|||||||
- `whatsapp.selfChatMode` (same-phone setup; bot uses your personal WhatsApp number).
|
- `whatsapp.selfChatMode` (same-phone setup; bot uses your personal WhatsApp number).
|
||||||
- `whatsapp.allowFrom` (DM allowlist).
|
- `whatsapp.allowFrom` (DM allowlist).
|
||||||
- `whatsapp.mediaMaxMb` (inbound media save cap).
|
- `whatsapp.mediaMaxMb` (inbound media save cap).
|
||||||
|
- `whatsapp.ackReaction` (auto-reaction on message receipt: `{emoji, direct, group}`).
|
||||||
- `whatsapp.accounts.<accountId>.*` (per-account settings + optional `authDir`).
|
- `whatsapp.accounts.<accountId>.*` (per-account settings + optional `authDir`).
|
||||||
- `whatsapp.accounts.<accountId>.mediaMaxMb` (per-account inbound media cap).
|
- `whatsapp.accounts.<accountId>.mediaMaxMb` (per-account inbound media cap).
|
||||||
|
- `whatsapp.accounts.<accountId>.ackReaction` (per-account ack reaction override).
|
||||||
- `whatsapp.groupAllowFrom` (group sender allowlist).
|
- `whatsapp.groupAllowFrom` (group sender allowlist).
|
||||||
- `whatsapp.groupPolicy` (group policy).
|
- `whatsapp.groupPolicy` (group policy).
|
||||||
- `whatsapp.historyLimit` / `whatsapp.accounts.<accountId>.historyLimit` (group history context; `0` disables).
|
- `whatsapp.historyLimit` / `whatsapp.accounts.<accountId>.historyLimit` (group history context; `0` disables).
|
||||||
|
|||||||
@@ -169,6 +169,21 @@ export type WhatsAppConfig = {
|
|||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
/** Acknowledgment reaction sent immediately upon message receipt. */
|
||||||
|
ackReaction?: {
|
||||||
|
/** Emoji to use for acknowledgment (e.g., "👀"). Empty = disabled. */
|
||||||
|
emoji?: string;
|
||||||
|
/** Send reactions in direct chats. Default: true. */
|
||||||
|
direct?: boolean;
|
||||||
|
/**
|
||||||
|
* Send reactions in group chats:
|
||||||
|
* - "always": react to all group messages
|
||||||
|
* - "mentions": react only when bot is mentioned
|
||||||
|
* - "never": never react in groups
|
||||||
|
* Default: "mentions"
|
||||||
|
*/
|
||||||
|
group?: "always" | "mentions" | "never";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WhatsAppAccountConfig = {
|
export type WhatsAppAccountConfig = {
|
||||||
@@ -202,6 +217,21 @@ export type WhatsAppAccountConfig = {
|
|||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
/** Acknowledgment reaction sent immediately upon message receipt. */
|
||||||
|
ackReaction?: {
|
||||||
|
/** Emoji to use for acknowledgment (e.g., "👀"). Empty = disabled. */
|
||||||
|
emoji?: string;
|
||||||
|
/** Send reactions in direct chats. Default: true. */
|
||||||
|
direct?: boolean;
|
||||||
|
/**
|
||||||
|
* Send reactions in group chats:
|
||||||
|
* - "always": react to all group messages
|
||||||
|
* - "mentions": react only when bot is mentioned
|
||||||
|
* - "never": never react in groups
|
||||||
|
* Default: "mentions"
|
||||||
|
*/
|
||||||
|
group?: "always" | "mentions" | "never";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BrowserProfileConfig = {
|
export type BrowserProfileConfig = {
|
||||||
|
|||||||
@@ -1375,6 +1375,16 @@ export const ClawdbotSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
ackReaction: z
|
||||||
|
.object({
|
||||||
|
emoji: z.string().optional(),
|
||||||
|
direct: z.boolean().optional().default(true),
|
||||||
|
group: z
|
||||||
|
.enum(["always", "mentions", "never"])
|
||||||
|
.optional()
|
||||||
|
.default("mentions"),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
if (value.dmPolicy !== "open") return;
|
if (value.dmPolicy !== "open") return;
|
||||||
@@ -1421,6 +1431,16 @@ export const ClawdbotSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
ackReaction: z
|
||||||
|
.object({
|
||||||
|
emoji: z.string().optional(),
|
||||||
|
direct: z.boolean().optional().default(true),
|
||||||
|
group: z
|
||||||
|
.enum(["always", "mentions", "never"])
|
||||||
|
.optional()
|
||||||
|
.default("mentions"),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
if (value.dmPolicy !== "open") return;
|
if (value.dmPolicy !== "open") return;
|
||||||
|
|||||||
@@ -1,303 +1,260 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { ClawdbotConfig } from "../config/types.js";
|
import type { ClawdbotConfig } from "../config/types.js";
|
||||||
|
|
||||||
describe("WhatsApp ack reaction", () => {
|
describe("WhatsApp ack reaction logic", () => {
|
||||||
beforeEach(() => {
|
// Helper to simulate the logic from auto-reply.ts
|
||||||
vi.clearAllMocks();
|
function shouldSendReaction(
|
||||||
});
|
cfg: ClawdbotConfig,
|
||||||
|
msg: {
|
||||||
it("should send ack reaction in direct chat when scope is 'all'", async () => {
|
id?: string;
|
||||||
const cfg: ClawdbotConfig = {
|
chatType: "direct" | "group";
|
||||||
messages: {
|
wasMentioned?: boolean;
|
||||||
ackReaction: "👀",
|
|
||||||
ackReactionScope: "all",
|
|
||||||
},
|
},
|
||||||
};
|
groupActivation?: "always" | "mention",
|
||||||
|
): boolean {
|
||||||
|
const ackConfig = cfg.whatsapp?.ackReaction;
|
||||||
|
const emoji = (ackConfig?.emoji ?? "").trim();
|
||||||
|
const directEnabled = ackConfig?.direct ?? true;
|
||||||
|
const groupMode = ackConfig?.group ?? "mentions";
|
||||||
|
|
||||||
// Simulate the logic from auto-reply.ts
|
if (!emoji) return false;
|
||||||
const msg = {
|
|
||||||
id: "msg123",
|
|
||||||
chatId: "123456789@s.whatsapp.net",
|
|
||||||
chatType: "direct" as const,
|
|
||||||
from: "+1234567890",
|
|
||||||
to: "+9876543210",
|
|
||||||
body: "hello",
|
|
||||||
};
|
|
||||||
|
|
||||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
|
||||||
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
|
||||||
const didSendReply = true;
|
|
||||||
|
|
||||||
const shouldAckReaction = () => {
|
|
||||||
if (!ackReaction) return false;
|
|
||||||
if (!msg.id) return false;
|
if (!msg.id) return false;
|
||||||
if (!didSendReply) return false;
|
|
||||||
if (ackReactionScope === "all") return true;
|
// Direct chat logic
|
||||||
if (ackReactionScope === "direct") return msg.chatType === "direct";
|
if (msg.chatType === "direct") {
|
||||||
if (ackReactionScope === "group-all") return msg.chatType === "group";
|
return directEnabled;
|
||||||
if (ackReactionScope === "group-mentions") {
|
|
||||||
if (msg.chatType !== "group") return false;
|
|
||||||
return false; // Would check wasMentioned
|
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(shouldAckReaction()).toBe(true);
|
// Group chat logic
|
||||||
});
|
if (msg.chatType === "group") {
|
||||||
|
if (groupMode === "never") return false;
|
||||||
it("should send ack reaction in direct chat when scope is 'direct'", async () => {
|
if (groupMode === "always") return true;
|
||||||
const cfg: ClawdbotConfig = {
|
if (groupMode === "mentions") {
|
||||||
messages: {
|
// If group activation is "always", always react
|
||||||
ackReaction: "👀",
|
if (groupActivation === "always") return true;
|
||||||
ackReactionScope: "direct",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const msg = {
|
|
||||||
id: "msg123",
|
|
||||||
chatId: "123456789@s.whatsapp.net",
|
|
||||||
chatType: "direct" as const,
|
|
||||||
from: "+1234567890",
|
|
||||||
to: "+9876543210",
|
|
||||||
body: "hello",
|
|
||||||
};
|
|
||||||
|
|
||||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
|
||||||
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
|
||||||
const didSendReply = true;
|
|
||||||
|
|
||||||
const shouldAckReaction = () => {
|
|
||||||
if (!ackReaction) return false;
|
|
||||||
if (!msg.id) return false;
|
|
||||||
if (!didSendReply) return false;
|
|
||||||
if (ackReactionScope === "all") return true;
|
|
||||||
if (ackReactionScope === "direct") return msg.chatType === "direct";
|
|
||||||
if (ackReactionScope === "group-all") return msg.chatType === "group";
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(shouldAckReaction()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should NOT send ack reaction in group when scope is 'direct'", async () => {
|
|
||||||
const cfg: ClawdbotConfig = {
|
|
||||||
messages: {
|
|
||||||
ackReaction: "👀",
|
|
||||||
ackReactionScope: "direct",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const msg = {
|
|
||||||
id: "msg123",
|
|
||||||
chatId: "123456789-group@g.us",
|
|
||||||
chatType: "group" as const,
|
|
||||||
from: "123456789-group@g.us",
|
|
||||||
to: "+9876543210",
|
|
||||||
body: "hello",
|
|
||||||
wasMentioned: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
|
||||||
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
|
||||||
const didSendReply = true;
|
|
||||||
|
|
||||||
const shouldAckReaction = () => {
|
|
||||||
if (!ackReaction) return false;
|
|
||||||
if (!msg.id) return false;
|
|
||||||
if (!didSendReply) return false;
|
|
||||||
if (ackReactionScope === "all") return true;
|
|
||||||
if (ackReactionScope === "direct") return msg.chatType === "direct";
|
|
||||||
if (ackReactionScope === "group-all") return msg.chatType === "group";
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(shouldAckReaction()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should send ack reaction in group when mentioned and scope is 'group-mentions' (requireMention=true)", async () => {
|
|
||||||
const cfg: ClawdbotConfig = {
|
|
||||||
messages: {
|
|
||||||
ackReaction: "👀",
|
|
||||||
ackReactionScope: "group-mentions",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const msg = {
|
|
||||||
id: "msg123",
|
|
||||||
chatId: "123456789-group@g.us",
|
|
||||||
chatType: "group" as const,
|
|
||||||
from: "123456789-group@g.us",
|
|
||||||
to: "+9876543210",
|
|
||||||
body: "hello @bot",
|
|
||||||
wasMentioned: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
|
||||||
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
|
||||||
const didSendReply = true;
|
|
||||||
const requireMention = true; // Simulated from activation check
|
|
||||||
|
|
||||||
const shouldAckReaction = () => {
|
|
||||||
if (!ackReaction) return false;
|
|
||||||
if (!msg.id) return false;
|
|
||||||
if (!didSendReply) return false;
|
|
||||||
if (ackReactionScope === "all") return true;
|
|
||||||
if (ackReactionScope === "direct") return msg.chatType === "direct";
|
|
||||||
if (ackReactionScope === "group-all") return msg.chatType === "group";
|
|
||||||
if (ackReactionScope === "group-mentions") {
|
|
||||||
if (msg.chatType !== "group") return false;
|
|
||||||
// If mention is not required (activation === "always"), always react
|
|
||||||
if (!requireMention) return true;
|
|
||||||
// Otherwise, only react if bot was mentioned
|
// Otherwise, only react if bot was mentioned
|
||||||
return msg.wasMentioned === true;
|
return msg.wasMentioned === true;
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(shouldAckReaction()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should send ack reaction in group when requireMention=false and scope is 'group-mentions' (activation: always)", async () => {
|
|
||||||
const cfg: ClawdbotConfig = {
|
|
||||||
messages: {
|
|
||||||
ackReaction: "👀",
|
|
||||||
ackReactionScope: "group-mentions",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const msg = {
|
|
||||||
id: "msg123",
|
|
||||||
chatId: "123456789-group@g.us",
|
|
||||||
chatType: "group" as const,
|
|
||||||
from: "123456789-group@g.us",
|
|
||||||
to: "+9876543210",
|
|
||||||
body: "hello",
|
|
||||||
wasMentioned: false, // No mention, but activation is "always"
|
|
||||||
};
|
|
||||||
|
|
||||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
|
||||||
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
|
||||||
const didSendReply = true;
|
|
||||||
const requireMention = false; // activation === "always"
|
|
||||||
|
|
||||||
const shouldAckReaction = () => {
|
|
||||||
if (!ackReaction) return false;
|
|
||||||
if (!msg.id) return false;
|
|
||||||
if (!didSendReply) return false;
|
|
||||||
if (ackReactionScope === "all") return true;
|
|
||||||
if (ackReactionScope === "direct") return msg.chatType === "direct";
|
|
||||||
if (ackReactionScope === "group-all") return msg.chatType === "group";
|
|
||||||
if (ackReactionScope === "group-mentions") {
|
|
||||||
if (msg.chatType !== "group") return false;
|
|
||||||
// If mention is not required (activation === "always"), always react
|
|
||||||
if (!requireMention) return true;
|
|
||||||
return msg.wasMentioned === true;
|
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(shouldAckReaction()).toBe(true);
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("direct chat", () => {
|
||||||
|
it("should react when direct=true", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
whatsapp: { ackReaction: { emoji: "👀", direct: true } },
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
shouldSendReaction(cfg, {
|
||||||
|
id: "msg1",
|
||||||
|
chatType: "direct",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should NOT send ack reaction in group when NOT mentioned and scope is 'group-mentions' (requireMention=true)", async () => {
|
it("should not react when direct=false", () => {
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
messages: {
|
whatsapp: { ackReaction: { emoji: "👀", direct: false } },
|
||||||
ackReaction: "👀",
|
|
||||||
ackReactionScope: "group-mentions",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
expect(
|
||||||
|
shouldSendReaction(cfg, {
|
||||||
|
id: "msg1",
|
||||||
|
chatType: "direct",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
const msg = {
|
it("should not react when emoji is empty", () => {
|
||||||
id: "msg123",
|
const cfg: ClawdbotConfig = {
|
||||||
chatId: "123456789-group@g.us",
|
whatsapp: { ackReaction: { emoji: "", direct: true } },
|
||||||
chatType: "group" as const,
|
};
|
||||||
from: "123456789-group@g.us",
|
expect(
|
||||||
to: "+9876543210",
|
shouldSendReaction(cfg, {
|
||||||
body: "hello",
|
id: "msg1",
|
||||||
|
chatType: "direct",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("group chat - always mode", () => {
|
||||||
|
it("should react to all messages when group=always", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
whatsapp: { ackReaction: { emoji: "👀", group: "always" } },
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
shouldSendReaction(cfg, {
|
||||||
|
id: "msg1",
|
||||||
|
chatType: "group",
|
||||||
wasMentioned: false,
|
wasMentioned: false,
|
||||||
};
|
}),
|
||||||
|
).toBe(true);
|
||||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
|
||||||
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
|
||||||
const didSendReply = true;
|
|
||||||
const requireMention = true;
|
|
||||||
|
|
||||||
const shouldAckReaction = () => {
|
|
||||||
if (!ackReaction) return false;
|
|
||||||
if (!msg.id) return false;
|
|
||||||
if (!didSendReply) return false;
|
|
||||||
if (ackReactionScope === "all") return true;
|
|
||||||
if (ackReactionScope === "direct") return msg.chatType === "direct";
|
|
||||||
if (ackReactionScope === "group-all") return msg.chatType === "group";
|
|
||||||
if (ackReactionScope === "group-mentions") {
|
|
||||||
if (msg.chatType !== "group") return false;
|
|
||||||
// If mention is not required (activation === "always"), always react
|
|
||||||
if (!requireMention) return true;
|
|
||||||
return msg.wasMentioned === true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(shouldAckReaction()).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should NOT send ack reaction when no reply was sent", async () => {
|
it("should react even with mention when group=always", () => {
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
messages: {
|
whatsapp: { ackReaction: { emoji: "👀", group: "always" } },
|
||||||
ackReaction: "👀",
|
};
|
||||||
ackReactionScope: "all",
|
expect(
|
||||||
|
shouldSendReaction(cfg, {
|
||||||
|
id: "msg1",
|
||||||
|
chatType: "group",
|
||||||
|
wasMentioned: true,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("group chat - mentions mode", () => {
|
||||||
|
it("should react when mentioned", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
whatsapp: { ackReaction: { emoji: "👀", group: "mentions" } },
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
shouldSendReaction(cfg, {
|
||||||
|
id: "msg1",
|
||||||
|
chatType: "group",
|
||||||
|
wasMentioned: true,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not react when not mentioned", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
whatsapp: { ackReaction: { emoji: "👀", group: "mentions" } },
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
shouldSendReaction(
|
||||||
|
cfg,
|
||||||
|
{
|
||||||
|
id: "msg1",
|
||||||
|
chatType: "group",
|
||||||
|
wasMentioned: false,
|
||||||
|
},
|
||||||
|
"mention", // group activation
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should react to all messages when group activation is always", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
whatsapp: { ackReaction: { emoji: "👀", group: "mentions" } },
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
shouldSendReaction(
|
||||||
|
cfg,
|
||||||
|
{
|
||||||
|
id: "msg1",
|
||||||
|
chatType: "group",
|
||||||
|
wasMentioned: false,
|
||||||
|
},
|
||||||
|
"always", // group has requireMention=false
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("group chat - never mode", () => {
|
||||||
|
it("should not react even with mention", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
whatsapp: { ackReaction: { emoji: "👀", group: "never" } },
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
shouldSendReaction(cfg, {
|
||||||
|
id: "msg1",
|
||||||
|
chatType: "group",
|
||||||
|
wasMentioned: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not react without mention", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
whatsapp: { ackReaction: { emoji: "👀", group: "never" } },
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
shouldSendReaction(cfg, {
|
||||||
|
id: "msg1",
|
||||||
|
chatType: "group",
|
||||||
|
wasMentioned: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("combinations", () => {
|
||||||
|
it("direct=false, group=always: only groups", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
whatsapp: {
|
||||||
|
ackReaction: { emoji: "✅", direct: false, group: "always" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const msg = {
|
expect(
|
||||||
id: "msg123",
|
shouldSendReaction(cfg, { id: "m1", chatType: "direct" }),
|
||||||
chatId: "123456789@s.whatsapp.net",
|
).toBe(false);
|
||||||
chatType: "direct" as const,
|
|
||||||
from: "+1234567890",
|
|
||||||
to: "+9876543210",
|
|
||||||
body: "hello",
|
|
||||||
};
|
|
||||||
|
|
||||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
expect(
|
||||||
const didSendReply = false; // No reply sent
|
shouldSendReaction(cfg, {
|
||||||
|
id: "m2",
|
||||||
const shouldAckReaction = () => {
|
chatType: "group",
|
||||||
if (!ackReaction) return false;
|
wasMentioned: false,
|
||||||
if (!msg.id) return false;
|
}),
|
||||||
if (!didSendReply) return false;
|
).toBe(true);
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(shouldAckReaction()).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should NOT send ack reaction when ackReaction is empty", async () => {
|
it("direct=true, group=never: only direct", () => {
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
messages: {
|
whatsapp: {
|
||||||
ackReaction: "",
|
ackReaction: { emoji: "🤖", direct: true, group: "never" },
|
||||||
ackReactionScope: "all",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const msg = {
|
expect(
|
||||||
id: "msg123",
|
shouldSendReaction(cfg, { id: "m1", chatType: "direct" }),
|
||||||
chatId: "123456789@s.whatsapp.net",
|
).toBe(true);
|
||||||
chatType: "direct" as const,
|
|
||||||
from: "+1234567890",
|
|
||||||
to: "+9876543210",
|
|
||||||
body: "hello",
|
|
||||||
};
|
|
||||||
|
|
||||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
expect(
|
||||||
const didSendReply = true;
|
shouldSendReaction(cfg, {
|
||||||
|
id: "m2",
|
||||||
const shouldAckReaction = () => {
|
chatType: "group",
|
||||||
if (!ackReaction) return false;
|
wasMentioned: true,
|
||||||
if (!msg.id) return false;
|
}),
|
||||||
if (!didSendReply) return false;
|
).toBe(false);
|
||||||
return true;
|
});
|
||||||
};
|
});
|
||||||
|
|
||||||
expect(shouldAckReaction()).toBe(false);
|
describe("defaults", () => {
|
||||||
|
it("should default direct=true", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
whatsapp: { ackReaction: { emoji: "👀" } },
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
shouldSendReaction(cfg, { id: "m1", chatType: "direct" }),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should default group=mentions", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
whatsapp: { ackReaction: { emoji: "👀" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldSendReaction(cfg, {
|
||||||
|
id: "m1",
|
||||||
|
chatType: "group",
|
||||||
|
wasMentioned: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldSendReaction(cfg, {
|
||||||
|
id: "m2",
|
||||||
|
chatType: "group",
|
||||||
|
wasMentioned: true,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1149,6 +1149,95 @@ export async function monitorWebProvider(
|
|||||||
status.lastMessageAt = Date.now();
|
status.lastMessageAt = Date.now();
|
||||||
status.lastEventAt = status.lastMessageAt;
|
status.lastEventAt = status.lastMessageAt;
|
||||||
emitStatus();
|
emitStatus();
|
||||||
|
|
||||||
|
// Send ack reaction immediately upon message receipt
|
||||||
|
if (msg.id) {
|
||||||
|
const ackConfig = cfg.whatsapp?.ackReaction;
|
||||||
|
// Backward compatibility: support old messages.ackReaction format
|
||||||
|
const legacyEmoji = (cfg.messages as any)?.ackReaction;
|
||||||
|
const legacyScope = (cfg.messages as any)?.ackReactionScope;
|
||||||
|
let emoji = (ackConfig?.emoji ?? "").trim();
|
||||||
|
let directEnabled = ackConfig?.direct ?? true;
|
||||||
|
let groupMode = ackConfig?.group ?? "mentions";
|
||||||
|
|
||||||
|
// Fallback to legacy config if new config is not set
|
||||||
|
if (!emoji && typeof legacyEmoji === "string") {
|
||||||
|
emoji = legacyEmoji.trim();
|
||||||
|
if (legacyScope === "all") {
|
||||||
|
directEnabled = true;
|
||||||
|
groupMode = "always";
|
||||||
|
} else if (legacyScope === "direct") {
|
||||||
|
directEnabled = true;
|
||||||
|
groupMode = "never";
|
||||||
|
} else if (legacyScope === "group-all") {
|
||||||
|
directEnabled = false;
|
||||||
|
groupMode = "always";
|
||||||
|
} else if (legacyScope === "group-mentions") {
|
||||||
|
directEnabled = false;
|
||||||
|
groupMode = "mentions";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversationIdForCheck = msg.conversationId ?? msg.from;
|
||||||
|
|
||||||
|
const shouldSendReaction = () => {
|
||||||
|
if (!emoji) return false;
|
||||||
|
|
||||||
|
// Direct chat logic
|
||||||
|
if (msg.chatType === "direct") {
|
||||||
|
return directEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group chat logic
|
||||||
|
if (msg.chatType === "group") {
|
||||||
|
if (groupMode === "never") return false;
|
||||||
|
if (groupMode === "always") {
|
||||||
|
// Always react to group messages
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (groupMode === "mentions") {
|
||||||
|
// Check if group has requireMention setting
|
||||||
|
const activation = resolveGroupActivationFor({
|
||||||
|
agentId: route.agentId,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
conversationId: conversationIdForCheck,
|
||||||
|
});
|
||||||
|
// If group activation is "always" (requireMention=false), react to all
|
||||||
|
if (activation === "always") return true;
|
||||||
|
// Otherwise, only react if bot was mentioned
|
||||||
|
return msg.wasMentioned === true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (shouldSendReaction()) {
|
||||||
|
replyLogger.info(
|
||||||
|
{ chatId: msg.chatId, messageId: msg.id, emoji },
|
||||||
|
"sending ack reaction",
|
||||||
|
);
|
||||||
|
sendReactionWhatsApp(msg.chatId, msg.id, emoji, {
|
||||||
|
verbose,
|
||||||
|
fromMe: false,
|
||||||
|
participant: msg.senderJid,
|
||||||
|
accountId: route.accountId,
|
||||||
|
}).catch((err) => {
|
||||||
|
replyLogger.warn(
|
||||||
|
{
|
||||||
|
error: formatError(err),
|
||||||
|
chatId: msg.chatId,
|
||||||
|
messageId: msg.id,
|
||||||
|
},
|
||||||
|
"failed to send ack reaction",
|
||||||
|
);
|
||||||
|
logVerbose(
|
||||||
|
`WhatsApp ack reaction failed for chat ${msg.chatId}: ${formatError(err)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const conversationId = msg.conversationId ?? msg.from;
|
const conversationId = msg.conversationId ?? msg.from;
|
||||||
let combinedBody = buildLine(msg, route.agentId);
|
let combinedBody = buildLine(msg, route.agentId);
|
||||||
let shouldClearGroupHistory = false;
|
let shouldClearGroupHistory = false;
|
||||||
@@ -1387,48 +1476,6 @@ export async function monitorWebProvider(
|
|||||||
groupHistories.set(groupHistoryKey, []);
|
groupHistories.set(groupHistoryKey, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send ack reaction after successful reply
|
|
||||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
|
||||||
const ackReactionScope =
|
|
||||||
cfg.messages?.ackReactionScope ?? "group-mentions";
|
|
||||||
const shouldAckReaction = () => {
|
|
||||||
if (!ackReaction) return false;
|
|
||||||
if (!msg.id) return false;
|
|
||||||
if (!didSendReply) return false;
|
|
||||||
if (ackReactionScope === "all") return true;
|
|
||||||
if (ackReactionScope === "direct") return msg.chatType === "direct";
|
|
||||||
if (ackReactionScope === "group-all") return msg.chatType === "group";
|
|
||||||
if (ackReactionScope === "group-mentions") {
|
|
||||||
if (msg.chatType !== "group") return false;
|
|
||||||
const activation = resolveGroupActivationFor({
|
|
||||||
agentId: route.agentId,
|
|
||||||
sessionKey: route.sessionKey,
|
|
||||||
conversationId,
|
|
||||||
});
|
|
||||||
const requireMention = activation !== "always";
|
|
||||||
// If mention is not required (activation === "always"), always react
|
|
||||||
if (!requireMention) return true;
|
|
||||||
// Otherwise, only react if bot was mentioned
|
|
||||||
return msg.wasMentioned === true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (shouldAckReaction() && msg.id) {
|
|
||||||
sendReactionWhatsApp(msg.chatId, msg.id, ackReaction, {
|
|
||||||
verbose,
|
|
||||||
fromMe: false,
|
|
||||||
}).catch((err) => {
|
|
||||||
replyLogger.warn(
|
|
||||||
{ error: formatError(err), chatId: msg.chatId, messageId: msg.id },
|
|
||||||
"failed to send ack reaction",
|
|
||||||
);
|
|
||||||
logVerbose(
|
|
||||||
`WhatsApp ack reaction failed for chat ${msg.chatId}: ${formatError(err)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return didSendReply;
|
return didSendReply;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user