feat: add group chat activation mode
parent
a0dd504991
commit
15e468f5dd
|
|
@ -73,13 +73,13 @@ Allowlist of E.164 phone numbers that may trigger auto-replies.
|
||||||
|
|
||||||
### `inbound.groupChat`
|
### `inbound.groupChat`
|
||||||
|
|
||||||
Group messages default to **require mention** (either metadata mention or regex patterns).
|
Group messages default to **require mention** (either metadata mention or regex patterns). You can switch to always-on activation.
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
inbound: {
|
inbound: {
|
||||||
groupChat: {
|
groupChat: {
|
||||||
requireMention: true,
|
activation: "mention", // mention | always
|
||||||
mentionPatterns: ["@clawd", "clawdbot", "clawd"],
|
mentionPatterns: ["@clawd", "clawdbot", "clawd"],
|
||||||
historyLimit: 50
|
historyLimit: 50
|
||||||
}
|
}
|
||||||
|
|
@ -87,6 +87,10 @@ Group messages default to **require mention** (either metadata mention or regex
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `activation` defaults to `mention`.
|
||||||
|
- `requireMention` is still supported for backwards compatibility (`false` ≈ `activation: "always"`).
|
||||||
|
|
||||||
### `inbound.workspace`
|
### `inbound.workspace`
|
||||||
|
|
||||||
Sets the **single global workspace directory** used by the agent for file operations.
|
Sets the **single global workspace directory** used by the agent for file operations.
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,13 @@ read_when:
|
||||||
Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that thread separate from the personal DM session.
|
Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that thread separate from the personal DM session.
|
||||||
|
|
||||||
## What’s implemented (2025-12-03)
|
## What’s implemented (2025-12-03)
|
||||||
- Mentions required by default: real WhatsApp @-mentions (via `mentionedJids`), regex patterns, or the bot’s E.164 anywhere in the text all count.
|
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token (see below).
|
||||||
- Group allowlist bypass: we still enforce `allowFrom` on the participant at inbox ingest, but group JIDs themselves no longer block replies.
|
- Group allowlist bypass: we still enforce `allowFrom` on the participant at inbox ingest, but group JIDs themselves no longer block replies.
|
||||||
- Per-group sessions: session keys look like `group:<jid>` so commands such as `/verbose on` or `/think:high` are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads.
|
- Per-group sessions: session keys look like `group:<jid>` so commands such as `/verbose on` or `/think:high` are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads.
|
||||||
- Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`.
|
- Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`.
|
||||||
- Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking.
|
- Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking.
|
||||||
- Ephemeral/view-once: we unwrap those before extracting text/mentions, so pings inside them still trigger.
|
- Ephemeral/view-once: we unwrap those before extracting text/mentions, so pings inside them still trigger.
|
||||||
- New session primer: on the first turn of a group session we now prepend a short blurb to the model like `You are replying inside the WhatsApp group "<subject>". Group members: +44..., +43..., … Address the specific sender noted in the message context.` If metadata isn’t available we still tell the agent it’s a group chat.
|
- New session primer: on the first turn of a group session we now prepend a short blurb to the model like `You are replying inside the WhatsApp group "<subject>". Group members: Alice (+44...), Bob (+43...), … Activation: trigger-only … Address the specific sender noted in the message context.` If metadata isn’t available we still tell the agent it’s a group chat.
|
||||||
|
|
||||||
## Config for Clawd UK (+447700900123)
|
## Config for Clawd UK (+447700900123)
|
||||||
Add a `groupChat` block to `~/.clawdis/clawdis.json` so display-name pings work even when WhatsApp strips the visual `@` in the text body:
|
Add a `groupChat` block to `~/.clawdis/clawdis.json` so display-name pings work even when WhatsApp strips the visual `@` in the text body:
|
||||||
|
|
@ -23,7 +23,7 @@ Add a `groupChat` block to `~/.clawdis/clawdis.json` so display-name pings work
|
||||||
{
|
{
|
||||||
"inbound": {
|
"inbound": {
|
||||||
"groupChat": {
|
"groupChat": {
|
||||||
"requireMention": true,
|
"activation": "mention",
|
||||||
"historyLimit": 50,
|
"historyLimit": 50,
|
||||||
"mentionPatterns": [
|
"mentionPatterns": [
|
||||||
"@?clawd",
|
"@?clawd",
|
||||||
|
|
@ -40,6 +40,10 @@ Notes:
|
||||||
- The regexes are case-insensitive; they cover `@clawd`, `@clawd uk`, `clawdbot`, and the raw number with or without `+`/spaces.
|
- The regexes are case-insensitive; they cover `@clawd`, `@clawd uk`, `clawdbot`, and the raw number with or without `+`/spaces.
|
||||||
- WhatsApp still sends canonical mentions via `mentionedJids` when someone taps the contact, so the number fallback is rarely needed but is a good safety net.
|
- WhatsApp still sends canonical mentions via `mentionedJids` when someone taps the contact, so the number fallback is rarely needed but is a good safety net.
|
||||||
|
|
||||||
|
### Always-on mode
|
||||||
|
|
||||||
|
Set `"activation": "always"` to wake on every group message. In this mode the agent is instructed to return `NO_REPLY` (exact token) when it decides no reply is necessary, and Clawdis will suppress the outbound message.
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
1) Add Clawd UK (`+447700900123`) to the group.
|
1) Add Clawd UK (`+447700900123`) to the group.
|
||||||
2) Say `@clawd …` (or `@clawd uk`, `@clawdbot`, or include the number). Anyone in the group can trigger it.
|
2) Say `@clawd …` (or `@clawd uk`, `@clawdbot`, or include the number). Anyone in the group can trigger it.
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export function buildAgentSystemPromptAppend(params: {
|
||||||
"",
|
"",
|
||||||
"## Messaging Safety",
|
"## Messaging Safety",
|
||||||
"Never send streaming/partial replies to external messaging surfaces; only final replies should be delivered there.",
|
"Never send streaming/partial replies to external messaging surfaces; only final replies should be delivered there.",
|
||||||
|
"Clawdis handles message transport automatically; respond normally and your reply will be delivered to the current chat.",
|
||||||
"",
|
"",
|
||||||
"## Heartbeats",
|
"## Heartbeats",
|
||||||
'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:',
|
'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:',
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import {
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
|
import { resolveGroupChatActivation } from "../config/group-chat.js";
|
||||||
import { logVerbose } from "../globals.js";
|
import { logVerbose } from "../globals.js";
|
||||||
import { buildProviderSummary } from "../infra/provider-summary.js";
|
import { buildProviderSummary } from "../infra/provider-summary.js";
|
||||||
import { triggerClawdisRestart } from "../infra/restart.js";
|
import { triggerClawdisRestart } from "../infra/restart.js";
|
||||||
|
|
@ -43,6 +44,7 @@ import {
|
||||||
} from "./thinking.js";
|
} from "./thinking.js";
|
||||||
import { isAudio, transcribeInboundAudio } from "./transcription.js";
|
import { isAudio, transcribeInboundAudio } from "./transcription.js";
|
||||||
import type { GetReplyOptions, ReplyPayload } from "./types.js";
|
import type { GetReplyOptions, ReplyPayload } from "./types.js";
|
||||||
|
import { SILENT_REPLY_TOKEN } from "./tokens.js";
|
||||||
|
|
||||||
export type { GetReplyOptions, ReplyPayload } from "./types.js";
|
export type { GetReplyOptions, ReplyPayload } from "./types.js";
|
||||||
|
|
||||||
|
|
@ -583,6 +585,7 @@ export async function getReplyFromConfig(
|
||||||
const groupIntro =
|
const groupIntro =
|
||||||
isFirstTurnInSession && sessionCtx.ChatType === "group"
|
isFirstTurnInSession && sessionCtx.ChatType === "group"
|
||||||
? (() => {
|
? (() => {
|
||||||
|
const activation = resolveGroupChatActivation(cfg);
|
||||||
const subject = sessionCtx.GroupSubject?.trim();
|
const subject = sessionCtx.GroupSubject?.trim();
|
||||||
const members = sessionCtx.GroupMembers?.trim();
|
const members = sessionCtx.GroupMembers?.trim();
|
||||||
const subjectLine = subject
|
const subjectLine = subject
|
||||||
|
|
@ -591,7 +594,25 @@ export async function getReplyFromConfig(
|
||||||
const membersLine = members
|
const membersLine = members
|
||||||
? `Group members: ${members}.`
|
? `Group members: ${members}.`
|
||||||
: undefined;
|
: undefined;
|
||||||
return [subjectLine, membersLine]
|
const activationLine =
|
||||||
|
activation === "always"
|
||||||
|
? "Activation: always-on (you receive every group message)."
|
||||||
|
: "Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included).";
|
||||||
|
const silenceLine =
|
||||||
|
activation === "always"
|
||||||
|
? `If no response is needed, reply with exactly "${SILENT_REPLY_TOKEN}" (no other text) so Clawdis stays silent.`
|
||||||
|
: undefined;
|
||||||
|
const cautionLine =
|
||||||
|
activation === "always"
|
||||||
|
? "Be extremely selective: reply only when you are directly addressed, asked a question, or can add clear value. Otherwise stay silent."
|
||||||
|
: undefined;
|
||||||
|
return [
|
||||||
|
subjectLine,
|
||||||
|
membersLine,
|
||||||
|
activationLine,
|
||||||
|
silenceLine,
|
||||||
|
cautionLine,
|
||||||
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")
|
.join(" ")
|
||||||
.concat(
|
.concat(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
|
||||||
|
export const SILENT_REPLY_TOKEN = "NO_REPLY";
|
||||||
|
|
@ -73,7 +73,10 @@ export type TelegramConfig = {
|
||||||
webhookPath?: string;
|
webhookPath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GroupChatActivationMode = "mention" | "always";
|
||||||
|
|
||||||
export type GroupChatConfig = {
|
export type GroupChatConfig = {
|
||||||
|
activation?: GroupChatActivationMode;
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
mentionPatterns?: string[];
|
mentionPatterns?: string[];
|
||||||
historyLimit?: number;
|
historyLimit?: number;
|
||||||
|
|
@ -289,6 +292,9 @@ const ClawdisSchema = z.object({
|
||||||
timestampPrefix: z.union([z.boolean(), z.string()]).optional(),
|
timestampPrefix: z.union([z.boolean(), z.string()]).optional(),
|
||||||
groupChat: z
|
groupChat: z
|
||||||
.object({
|
.object({
|
||||||
|
activation: z
|
||||||
|
.union([z.literal("mention"), z.literal("always")])
|
||||||
|
.optional(),
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
mentionPatterns: z.array(z.string()).optional(),
|
mentionPatterns: z.array(z.string()).optional(),
|
||||||
historyLimit: z.number().int().positive().optional(),
|
historyLimit: z.number().int().positive().optional(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import type { ClawdisConfig, GroupChatActivationMode } from "./config.js";
|
||||||
|
|
||||||
|
export function resolveGroupChatActivation(
|
||||||
|
cfg?: ClawdisConfig,
|
||||||
|
): GroupChatActivationMode {
|
||||||
|
const groupChat = cfg?.inbound?.groupChat;
|
||||||
|
if (groupChat?.activation === "always") return "always";
|
||||||
|
if (groupChat?.activation === "mention") return "mention";
|
||||||
|
if (groupChat?.requireMention === false) return "always";
|
||||||
|
return "mention";
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ import * as commandQueue from "../process/command-queue.js";
|
||||||
import {
|
import {
|
||||||
HEARTBEAT_PROMPT,
|
HEARTBEAT_PROMPT,
|
||||||
HEARTBEAT_TOKEN,
|
HEARTBEAT_TOKEN,
|
||||||
|
SILENT_REPLY_TOKEN,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
resolveHeartbeatRecipients,
|
resolveHeartbeatRecipients,
|
||||||
resolveReplyHeartbeatMinutes,
|
resolveReplyHeartbeatMinutes,
|
||||||
|
|
@ -1431,6 +1432,81 @@ describe("web auto-reply", () => {
|
||||||
expect(payload.Body).toContain("[from: Bob (+222)]");
|
expect(payload.Body).toContain("[from: Bob (+222)]");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("supports always-on group activation with silent token and preserves history", async () => {
|
||||||
|
const sendMedia = vi.fn();
|
||||||
|
const reply = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const sendComposing = vi.fn();
|
||||||
|
const resolver = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ text: SILENT_REPLY_TOKEN })
|
||||||
|
.mockResolvedValueOnce({ text: "ok" });
|
||||||
|
|
||||||
|
setLoadConfigMock(() => ({
|
||||||
|
inbound: {
|
||||||
|
groupChat: { activation: "always", mentionPatterns: ["@clawd"] },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
let capturedOnMessage:
|
||||||
|
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||||
|
| undefined;
|
||||||
|
const listenerFactory = async (opts: {
|
||||||
|
onMessage: (
|
||||||
|
msg: import("./inbound.js").WebInboundMessage,
|
||||||
|
) => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
capturedOnMessage = opts.onMessage;
|
||||||
|
return { close: vi.fn() };
|
||||||
|
};
|
||||||
|
|
||||||
|
await monitorWebProvider(false, listenerFactory, false, resolver);
|
||||||
|
expect(capturedOnMessage).toBeDefined();
|
||||||
|
|
||||||
|
await capturedOnMessage?.({
|
||||||
|
body: "first",
|
||||||
|
from: "123@g.us",
|
||||||
|
conversationId: "123@g.us",
|
||||||
|
chatId: "123@g.us",
|
||||||
|
chatType: "group",
|
||||||
|
to: "+2",
|
||||||
|
id: "g-always-1",
|
||||||
|
senderE164: "+111",
|
||||||
|
senderName: "Alice",
|
||||||
|
selfE164: "+999",
|
||||||
|
sendComposing,
|
||||||
|
reply,
|
||||||
|
sendMedia,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolver).toHaveBeenCalledTimes(1);
|
||||||
|
expect(reply).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await capturedOnMessage?.({
|
||||||
|
body: "second",
|
||||||
|
from: "123@g.us",
|
||||||
|
conversationId: "123@g.us",
|
||||||
|
chatId: "123@g.us",
|
||||||
|
chatType: "group",
|
||||||
|
to: "+2",
|
||||||
|
id: "g-always-2",
|
||||||
|
senderE164: "+222",
|
||||||
|
senderName: "Bob",
|
||||||
|
selfE164: "+999",
|
||||||
|
sendComposing,
|
||||||
|
reply,
|
||||||
|
sendMedia,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolver).toHaveBeenCalledTimes(2);
|
||||||
|
const payload = resolver.mock.calls[1][0];
|
||||||
|
expect(payload.Body).toContain("Chat messages since your last reply");
|
||||||
|
expect(payload.Body).toContain("Alice: first");
|
||||||
|
expect(payload.Body).toContain("Bob: second");
|
||||||
|
expect(reply).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
resetLoadConfigMock();
|
||||||
|
});
|
||||||
|
|
||||||
it("ignores JID mentions in self-chat mode (group chats)", async () => {
|
it("ignores JID mentions in self-chat mode (group chats)", async () => {
|
||||||
const sendMedia = vi.fn();
|
const sendMedia = vi.fn();
|
||||||
const reply = vi.fn().mockResolvedValue(undefined);
|
const reply = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,13 @@ import { chunkText } from "../auto-reply/chunk.js";
|
||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
|
import {
|
||||||
|
HEARTBEAT_TOKEN,
|
||||||
|
SILENT_REPLY_TOKEN,
|
||||||
|
} from "../auto-reply/tokens.js";
|
||||||
import { waitForever } from "../cli/wait.js";
|
import { waitForever } from "../cli/wait.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import { resolveGroupChatActivation } from "../config/group-chat.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_IDLE_MINUTES,
|
DEFAULT_IDLE_MINUTES,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
|
|
@ -77,8 +82,8 @@ const formatDuration = (ms: number) =>
|
||||||
ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
|
ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
|
||||||
|
|
||||||
const DEFAULT_REPLY_HEARTBEAT_MINUTES = 30;
|
const DEFAULT_REPLY_HEARTBEAT_MINUTES = 30;
|
||||||
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
|
|
||||||
export const HEARTBEAT_PROMPT = "HEARTBEAT";
|
export const HEARTBEAT_PROMPT = "HEARTBEAT";
|
||||||
|
export { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN };
|
||||||
|
|
||||||
export type WebProviderStatus = {
|
export type WebProviderStatus = {
|
||||||
running: boolean;
|
running: boolean;
|
||||||
|
|
@ -110,7 +115,9 @@ type MentionConfig = {
|
||||||
|
|
||||||
function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): MentionConfig {
|
function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): MentionConfig {
|
||||||
const gc = cfg.inbound?.groupChat;
|
const gc = cfg.inbound?.groupChat;
|
||||||
const requireMention = gc?.requireMention !== false; // default true
|
const activation = resolveGroupChatActivation(cfg);
|
||||||
|
const requireMention =
|
||||||
|
activation === "always" ? false : gc?.requireMention !== false; // default true
|
||||||
const mentionRegexes =
|
const mentionRegexes =
|
||||||
gc?.mentionPatterns
|
gc?.mentionPatterns
|
||||||
?.map((p) => {
|
?.map((p) => {
|
||||||
|
|
@ -207,6 +214,14 @@ export function stripHeartbeatToken(raw?: string) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSilentReply(payload?: ReplyPayload): boolean {
|
||||||
|
if (!payload) return false;
|
||||||
|
const text = payload.text?.trim();
|
||||||
|
if (!text || text !== SILENT_REPLY_TOKEN) return false;
|
||||||
|
if (payload.mediaUrl || payload.mediaUrls?.length) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runWebHeartbeatOnce(opts: {
|
export async function runWebHeartbeatOnce(opts: {
|
||||||
cfg?: ReturnType<typeof loadConfig>;
|
cfg?: ReturnType<typeof loadConfig>;
|
||||||
to: string;
|
to: string;
|
||||||
|
|
@ -760,6 +775,7 @@ export async function monitorWebProvider(
|
||||||
string,
|
string,
|
||||||
Array<{ sender: string; body: string; timestamp?: number }>
|
Array<{ sender: string; body: string; timestamp?: number }>
|
||||||
>();
|
>();
|
||||||
|
const groupMemberNames = new Map<string, Map<string, string>>();
|
||||||
const sleep =
|
const sleep =
|
||||||
tuning.sleep ??
|
tuning.sleep ??
|
||||||
((ms: number, signal?: AbortSignal) =>
|
((ms: number, signal?: AbortSignal) =>
|
||||||
|
|
@ -773,6 +789,60 @@ export async function monitorWebProvider(
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const noteGroupMember = (
|
||||||
|
conversationId: string,
|
||||||
|
e164?: string,
|
||||||
|
name?: string,
|
||||||
|
) => {
|
||||||
|
if (!e164 || !name) return;
|
||||||
|
const normalized = normalizeE164(e164);
|
||||||
|
const key = normalized ?? e164;
|
||||||
|
if (!key) return;
|
||||||
|
let roster = groupMemberNames.get(conversationId);
|
||||||
|
if (!roster) {
|
||||||
|
roster = new Map();
|
||||||
|
groupMemberNames.set(conversationId, roster);
|
||||||
|
}
|
||||||
|
roster.set(key, name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatGroupMembers = (
|
||||||
|
participants: string[] | undefined,
|
||||||
|
roster: Map<string, string> | undefined,
|
||||||
|
fallbackE164?: string,
|
||||||
|
) => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const ordered: string[] = [];
|
||||||
|
if (participants?.length) {
|
||||||
|
for (const entry of participants) {
|
||||||
|
if (!entry) continue;
|
||||||
|
const normalized = normalizeE164(entry) ?? entry;
|
||||||
|
if (!normalized || seen.has(normalized)) continue;
|
||||||
|
seen.add(normalized);
|
||||||
|
ordered.push(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (roster) {
|
||||||
|
for (const entry of roster.keys()) {
|
||||||
|
const normalized = normalizeE164(entry) ?? entry;
|
||||||
|
if (!normalized || seen.has(normalized)) continue;
|
||||||
|
seen.add(normalized);
|
||||||
|
ordered.push(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ordered.length === 0 && fallbackE164) {
|
||||||
|
const normalized = normalizeE164(fallbackE164) ?? fallbackE164;
|
||||||
|
if (normalized) ordered.push(normalized);
|
||||||
|
}
|
||||||
|
if (ordered.length === 0) return undefined;
|
||||||
|
return ordered
|
||||||
|
.map((entry) => {
|
||||||
|
const name = roster?.get(entry);
|
||||||
|
return name ? `${name} (${entry})` : entry;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
};
|
||||||
|
|
||||||
// Avoid noisy MaxListenersExceeded warnings in test environments where
|
// Avoid noisy MaxListenersExceeded warnings in test environments where
|
||||||
// multiple gateway instances may be constructed.
|
// multiple gateway instances may be constructed.
|
||||||
const currentMaxListeners = process.getMaxListeners?.() ?? 10;
|
const currentMaxListeners = process.getMaxListeners?.() ?? 10;
|
||||||
|
|
@ -843,6 +913,7 @@ export async function monitorWebProvider(
|
||||||
emitStatus();
|
emitStatus();
|
||||||
const conversationId = msg.conversationId ?? msg.from;
|
const conversationId = msg.conversationId ?? msg.from;
|
||||||
let combinedBody = buildLine(msg);
|
let combinedBody = buildLine(msg);
|
||||||
|
let shouldClearGroupHistory = false;
|
||||||
|
|
||||||
if (msg.chatType === "group") {
|
if (msg.chatType === "group") {
|
||||||
const history = groupHistories.get(conversationId) ?? [];
|
const history = groupHistories.get(conversationId) ?? [];
|
||||||
|
|
@ -867,8 +938,7 @@ export async function monitorWebProvider(
|
||||||
? `${msg.senderName} (${msg.senderE164})`
|
? `${msg.senderName} (${msg.senderE164})`
|
||||||
: (msg.senderName ?? msg.senderE164 ?? "Unknown");
|
: (msg.senderName ?? msg.senderE164 ?? "Unknown");
|
||||||
combinedBody = `${combinedBody}\\n[from: ${senderLabel}]`;
|
combinedBody = `${combinedBody}\\n[from: ${senderLabel}]`;
|
||||||
// Clear stored history after using it
|
shouldClearGroupHistory = true;
|
||||||
groupHistories.set(conversationId, []);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Echo detection uses combined body so we don't respond twice.
|
// Echo detection uses combined body so we don't respond twice.
|
||||||
|
|
@ -933,6 +1003,7 @@ export async function monitorWebProvider(
|
||||||
}
|
}
|
||||||
|
|
||||||
const responsePrefix = cfg.inbound?.responsePrefix;
|
const responsePrefix = cfg.inbound?.responsePrefix;
|
||||||
|
let didSendReply = false;
|
||||||
let toolSendChain: Promise<void> = Promise.resolve();
|
let toolSendChain: Promise<void> = Promise.resolve();
|
||||||
const sendToolResult = (payload: ReplyPayload) => {
|
const sendToolResult = (payload: ReplyPayload) => {
|
||||||
if (
|
if (
|
||||||
|
|
@ -942,6 +1013,7 @@ export async function monitorWebProvider(
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isSilentReply(payload)) return;
|
||||||
const toolPayload: ReplyPayload = { ...payload };
|
const toolPayload: ReplyPayload = { ...payload };
|
||||||
if (
|
if (
|
||||||
responsePrefix &&
|
responsePrefix &&
|
||||||
|
|
@ -961,6 +1033,7 @@ export async function monitorWebProvider(
|
||||||
connectionId,
|
connectionId,
|
||||||
skipLog: true,
|
skipLog: true,
|
||||||
});
|
});
|
||||||
|
didSendReply = true;
|
||||||
if (toolPayload.text) {
|
if (toolPayload.text) {
|
||||||
recentlySent.add(toolPayload.text);
|
recentlySent.add(toolPayload.text);
|
||||||
if (recentlySent.size > MAX_RECENT_MESSAGES) {
|
if (recentlySent.size > MAX_RECENT_MESSAGES) {
|
||||||
|
|
@ -987,7 +1060,11 @@ export async function monitorWebProvider(
|
||||||
MediaType: msg.mediaType,
|
MediaType: msg.mediaType,
|
||||||
ChatType: msg.chatType,
|
ChatType: msg.chatType,
|
||||||
GroupSubject: msg.groupSubject,
|
GroupSubject: msg.groupSubject,
|
||||||
GroupMembers: msg.groupParticipants?.join(", "),
|
GroupMembers: formatGroupMembers(
|
||||||
|
msg.groupParticipants,
|
||||||
|
groupMemberNames.get(conversationId),
|
||||||
|
msg.senderE164,
|
||||||
|
),
|
||||||
SenderName: msg.senderName,
|
SenderName: msg.senderName,
|
||||||
SenderE164: msg.senderE164,
|
SenderE164: msg.senderE164,
|
||||||
Surface: "whatsapp",
|
Surface: "whatsapp",
|
||||||
|
|
@ -1004,14 +1081,24 @@ export async function monitorWebProvider(
|
||||||
: [replyResult]
|
: [replyResult]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (replyList.length === 0) {
|
const sendableReplies = replyList.filter(
|
||||||
logVerbose("Skipping auto-reply: no text/media returned from resolver");
|
(payload) => !isSilentReply(payload),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sendableReplies.length === 0) {
|
||||||
|
await toolSendChain;
|
||||||
|
if (shouldClearGroupHistory && didSendReply) {
|
||||||
|
groupHistories.set(conversationId, []);
|
||||||
|
}
|
||||||
|
logVerbose(
|
||||||
|
"Skipping auto-reply: silent token or no text/media returned from resolver",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await toolSendChain;
|
await toolSendChain;
|
||||||
|
|
||||||
for (const replyPayload of replyList) {
|
for (const replyPayload of sendableReplies) {
|
||||||
if (
|
if (
|
||||||
responsePrefix &&
|
responsePrefix &&
|
||||||
replyPayload.text &&
|
replyPayload.text &&
|
||||||
|
|
@ -1029,6 +1116,7 @@ export async function monitorWebProvider(
|
||||||
replyLogger,
|
replyLogger,
|
||||||
connectionId,
|
connectionId,
|
||||||
});
|
});
|
||||||
|
didSendReply = true;
|
||||||
|
|
||||||
if (replyPayload.text) {
|
if (replyPayload.text) {
|
||||||
recentlySent.add(replyPayload.text);
|
recentlySent.add(replyPayload.text);
|
||||||
|
|
@ -1065,6 +1153,10 @@ export async function monitorWebProvider(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldClearGroupHistory && didSendReply) {
|
||||||
|
groupHistories.set(conversationId, []);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const listener = await (listenerFactory ?? monitorWebInbox)({
|
const listener = await (listenerFactory ?? monitorWebInbox)({
|
||||||
|
|
@ -1096,6 +1188,7 @@ export async function monitorWebProvider(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.chatType === "group") {
|
if (msg.chatType === "group") {
|
||||||
|
noteGroupMember(conversationId, msg.senderE164, msg.senderName);
|
||||||
const history =
|
const history =
|
||||||
groupHistories.get(conversationId) ??
|
groupHistories.get(conversationId) ??
|
||||||
([] as Array<{ sender: string; body: string; timestamp?: number }>);
|
([] as Array<{ sender: string; body: string; timestamp?: number }>);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue