security: fix remaining gaps in WhatsApp remediation
- Heartbeat now requires explicit target by default (throws error) - Empty automation.recipients blocks all automation sends - Send logging shows correct resolution source (explicit/directory/fallback) - Delivery context has 24h TTL and clears on channel logout - Legacy WhatsApp adapter now has automation checks - Request logging for all send operations - First-time recipient warnings - Dry-run mode support (CLAWDBOT_DRY_RUN=true) - Security documentation added
This commit is contained in:
parent
fc379a1332
commit
e712faadb9
329
docs/security/outbound-messaging.md
Normal file
329
docs/security/outbound-messaging.md
Normal file
@ -0,0 +1,329 @@
|
||||
---
|
||||
title: Outbound Messaging Security Model
|
||||
layout: default
|
||||
nav_order: 1
|
||||
parent: Security
|
||||
---
|
||||
|
||||
# Outbound Messaging Security Model
|
||||
|
||||
This document explains Clawdbot's outbound messaging security model, including allowlist enforcement, target resolution, sub-agent messaging best practices, and automation recipient configuration.
|
||||
|
||||
## Overview
|
||||
|
||||
Clawdbot implements multiple layers of security for outbound messaging to prevent unauthorized message sends:
|
||||
|
||||
1. **Allowlist Enforcement** - Validates targets against configured allowlists
|
||||
2. **Automation Recipients** - Separate allowlist for automated/system sends
|
||||
3. **Sub-Agent Restrictions** - Blocks direct message sends from sub-agents
|
||||
4. **Target Resolution** - Controlled resolution of message targets
|
||||
5. **Request Logging** - Full audit trail of all send operations
|
||||
6. **First-Time Recipient Warnings** - Alerts for sends to new recipients
|
||||
7. **Dry-Run Mode** - Development/testing mode to prevent accidental sends
|
||||
|
||||
## Allowlist Enforcement
|
||||
|
||||
### Configuration
|
||||
|
||||
Allowlists are configured per channel. For WhatsApp:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"whatsapp": {
|
||||
"allowFrom": ["+16505551234", "+16505555678"],
|
||||
"accounts": {
|
||||
"default": {
|
||||
"allowFrom": ["+16505551234"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Enforcement Rules
|
||||
|
||||
1. **Explicit Mode Sends** (CLI with `--target`, direct API calls):
|
||||
- Target MUST be in allowlist OR use `--allow-unlisted` flag
|
||||
- Groups are always allowed (no allowlist check)
|
||||
- Empty allowlist = all targets allowed
|
||||
|
||||
2. **Implicit Mode Sends** (session-derived routing):
|
||||
- If target not in allowlist, falls back to first allowlist entry
|
||||
- Provides safe default behavior
|
||||
|
||||
3. **Heartbeat/Automation Mode Sends**:
|
||||
- Validates against `automation.recipients` if configured
|
||||
- Falls back to general allowlist if automation.recipients is empty
|
||||
|
||||
### Security Bypass
|
||||
|
||||
The `--allow-unlisted` CLI flag allows sending to targets not in the allowlist:
|
||||
|
||||
```bash
|
||||
clawdbot message send --target +16505559999 --message "Hello" --allow-unlisted
|
||||
```
|
||||
|
||||
This is logged and should only be used for emergencies.
|
||||
|
||||
## Automation Recipients
|
||||
|
||||
### Purpose
|
||||
|
||||
The `automation.recipients` list provides a stricter allowlist specifically for automated sends (heartbeats, cron jobs, system events, daemon notifications).
|
||||
|
||||
### Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"whatsapp": {
|
||||
"automation": {
|
||||
"recipients": ["+16505551234"]
|
||||
},
|
||||
"accounts": {
|
||||
"default": {
|
||||
"automation": {
|
||||
"recipients": ["+16505551234"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
- If `automation.recipients` is configured (non-empty), only those numbers can receive automated sends
|
||||
- If empty/not configured, falls back to general `allowFrom` behavior
|
||||
- This separation prevents automation from accidentally messaging contacts that are allowed for interactive use but shouldn't receive automated notifications
|
||||
|
||||
## Sub-Agent Messaging Restrictions
|
||||
|
||||
### Default Behavior (FIX-1.5)
|
||||
|
||||
By default, sub-agents CANNOT directly call `clawdbot message send` or the message RPC API. This prevents:
|
||||
|
||||
- Prompt injection attacks causing sub-agents to spam messages
|
||||
- Sub-agents bypassing session routing controls
|
||||
- Accidental sends from poorly-written sub-agent prompts
|
||||
|
||||
### Blocked Actions
|
||||
|
||||
- `send` - Direct message sends
|
||||
- `poll` - Poll creation
|
||||
- `broadcast` - Broadcast sends
|
||||
|
||||
### Allowed Actions
|
||||
|
||||
Sub-agents can still use:
|
||||
- `sessions_send` tool - Routes through parent session context
|
||||
- Blessed callback mechanisms that go through proper routing
|
||||
|
||||
### Security Logging
|
||||
|
||||
Blocked attempts are logged:
|
||||
```
|
||||
[security] Subagent agent:main:subagent:abc123 attempted direct message send - blocked
|
||||
```
|
||||
|
||||
### Override (Not Recommended)
|
||||
|
||||
To allow direct sends (use with caution):
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"subagents": {
|
||||
"allowDirectMessageSend": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Target Resolution Behavior
|
||||
|
||||
### Resolution Modes
|
||||
|
||||
1. **Explicit** - Target specified directly by user/caller
|
||||
2. **Session** - Target derived from current session context
|
||||
3. **Fallback** - Target resolved from allowlist or directory
|
||||
4. **Allowlist** - Target normalized from allowlist entry
|
||||
5. **Directory** - Target resolved from contacts directory
|
||||
|
||||
### Resolution Order
|
||||
|
||||
For different send paths:
|
||||
|
||||
| Path | Resolution Priority |
|
||||
|------|---------------------|
|
||||
| CLI with `--target` | Explicit → Validate → Send |
|
||||
| Tool call | Explicit → Session → Fallback |
|
||||
| Heartbeat | Explicit → Automation Recipients → Allowlist[0] |
|
||||
| System Event | Requires explicit target (no fallback) |
|
||||
|
||||
### Logging
|
||||
|
||||
All target resolution is logged with source and method:
|
||||
```
|
||||
[send] source=cli channel=whatsapp target=+165***1234 resolvedFrom=explicit
|
||||
```
|
||||
|
||||
## Request Logging (FIX-2.2)
|
||||
|
||||
Every send operation is logged with full context:
|
||||
|
||||
### Log Format
|
||||
|
||||
```
|
||||
[send] source=cli|rpc|session|sub-agent|tool sessionKey=xxx channel=whatsapp target=+xxx resolvedFrom=explicit|session|fallback
|
||||
```
|
||||
|
||||
### Logged Fields
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| source | Origin of the send request |
|
||||
| sessionKey | Session key if applicable |
|
||||
| channel | Target channel (whatsapp, telegram, etc.) |
|
||||
| target | Target recipient (masked for privacy) |
|
||||
| resolvedTarget | Final resolved target if different |
|
||||
| resolvedFrom | How the target was resolved |
|
||||
| accountId | Channel account used |
|
||||
| dryRun | Whether this was a dry-run |
|
||||
| firstTime | Whether this is a first-time recipient |
|
||||
|
||||
## First-Time Recipient Warnings (FIX-3.1)
|
||||
|
||||
### Purpose
|
||||
|
||||
Provides visibility when automation sends to a new recipient for the first time, even if they're in the allowlist.
|
||||
|
||||
### Behavior
|
||||
|
||||
When sending to a recipient not previously contacted:
|
||||
|
||||
1. Warning is logged: `[whatsapp] First-time recipient: +165***1234 (in allowlist)`
|
||||
2. The send proceeds normally (not blocked)
|
||||
3. Recipient is recorded for future reference
|
||||
|
||||
### Storage
|
||||
|
||||
Known recipients are tracked in:
|
||||
```
|
||||
~/.clawdbot/known-recipients.json
|
||||
```
|
||||
|
||||
### Log Output
|
||||
|
||||
```
|
||||
[outbound/known-recipients] New recipient recorded: whatsapp:+165***1234
|
||||
[outbound/known-recipients] [whatsapp] First-time recipient: +165***1234 (in allowlist)
|
||||
```
|
||||
|
||||
## Dry-Run Mode (FIX-3.3)
|
||||
|
||||
### Purpose
|
||||
|
||||
Prevents actual message sends during development and testing.
|
||||
|
||||
### Enabling
|
||||
|
||||
**Environment Variable:**
|
||||
```bash
|
||||
export CLAWDBOT_DRY_RUN=true
|
||||
clawdbot gateway start
|
||||
```
|
||||
|
||||
**CLI Flag:**
|
||||
```bash
|
||||
clawdbot message send --target +1234 --message "test" --dry-run
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
When dry-run is enabled:
|
||||
- All outbound sends are logged but not executed
|
||||
- Log format: `[dry-run] would send to +xxx: message preview...`
|
||||
- API returns success with `dryRun: true` in response
|
||||
- No actual messages are delivered
|
||||
|
||||
### Log Output
|
||||
|
||||
```
|
||||
[outbound/dry-run] [dry-run] would send to +165***1234 channel=whatsapp source=cli message="Hello world"
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Operators
|
||||
|
||||
1. **Configure tight allowlists** - Only include numbers that should receive messages
|
||||
2. **Use automation.recipients** - Separate interactive contacts from automation targets
|
||||
3. **Enable dry-run for development** - Set `CLAWDBOT_DRY_RUN=true` when testing
|
||||
4. **Monitor first-time recipient warnings** - Review logs for unexpected new contacts
|
||||
5. **Review request logs** - Audit `[send]` log entries periodically
|
||||
|
||||
### For Sub-Agent Development
|
||||
|
||||
1. **Use sessions_send** - Route messages through parent session context
|
||||
2. **Don't call clawdbot message send directly** - It will be blocked
|
||||
3. **Specify explicit targets** - Don't rely on implicit routing
|
||||
4. **Handle blocked sends gracefully** - Catch and log errors
|
||||
|
||||
### For Automation
|
||||
|
||||
1. **Always specify --target and --channel** - Never rely on defaults
|
||||
2. **Use automation.recipients** - Configure explicit automation targets
|
||||
3. **Test with dry-run** - Verify behavior before enabling real sends
|
||||
4. **Log all send attempts** - Maintain audit trail
|
||||
|
||||
## Security Incident Response
|
||||
|
||||
If an unauthorized send occurs:
|
||||
|
||||
1. **Check request logs** - Find `[send]` entries around the incident time
|
||||
2. **Review source field** - Identify what triggered the send
|
||||
3. **Check session key** - Trace to the originating session
|
||||
4. **Audit target resolution** - Verify how the target was resolved
|
||||
5. **Review known-recipients.json** - Check if recipient was previously known
|
||||
|
||||
## Related Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"whatsapp": {
|
||||
"allowFrom": ["..."],
|
||||
"automation": {
|
||||
"recipients": ["..."]
|
||||
}
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"subagents": {
|
||||
"allowDirectMessageSend": false
|
||||
},
|
||||
"heartbeat": {
|
||||
"requireExplicitTarget": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CLAWDBOT_DRY_RUN` | Enable dry-run mode (true/1/yes) |
|
||||
| `CLAWDBOT_STATE_DIR` | Location for known-recipients.json |
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-01-25*
|
||||
@ -279,7 +279,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
chunkerMode: "text",
|
||||
textChunkLimit: 4000,
|
||||
pollMaxOptions: 12,
|
||||
resolveTarget: ({ to, allowFrom, mode, allowUnlisted }) => {
|
||||
resolveTarget: ({ cfg, to, allowFrom, accountId, mode, allowUnlisted }) => {
|
||||
const trimmed = to?.trim() ?? "";
|
||||
const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
||||
const hasWildcard = allowListRaw.includes("*");
|
||||
@ -288,6 +288,25 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
.map((entry) => normalizeWhatsAppTarget(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
|
||||
// FIX-1.6: Resolve automation recipients for heartbeat/automation mode validation
|
||||
const isAutomationMode = mode === "heartbeat" || mode === "automation";
|
||||
// Track whether automation.recipients is explicitly configured (vs undefined)
|
||||
// Empty array = "no automation allowed", undefined = "fall back to allowlist"
|
||||
let automationRecipientsConfigured = false;
|
||||
let automationRecipients: string[] = [];
|
||||
if (isAutomationMode && cfg) {
|
||||
const account = resolveWhatsAppAccount({ cfg, accountId });
|
||||
const accountAutomation = account.automation?.recipients;
|
||||
const globalAutomation = cfg.channels?.whatsapp?.automation?.recipients;
|
||||
// Check if automation.recipients is explicitly configured at any level
|
||||
automationRecipientsConfigured =
|
||||
accountAutomation !== undefined || globalAutomation !== undefined;
|
||||
const rawRecipients = accountAutomation ?? globalAutomation ?? [];
|
||||
automationRecipients = rawRecipients
|
||||
.map((entry) => normalizeWhatsAppTarget(String(entry).trim()))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
}
|
||||
|
||||
if (trimmed) {
|
||||
const normalizedTo = normalizeWhatsAppTarget(trimmed);
|
||||
if (!normalizedTo) {
|
||||
@ -306,7 +325,52 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
if (isWhatsAppGroupJid(normalizedTo)) {
|
||||
return { ok: true, to: normalizedTo };
|
||||
}
|
||||
if (mode === "implicit" || mode === "heartbeat") {
|
||||
|
||||
// FIX-1.6: Validate automation recipients for heartbeat/automation sends
|
||||
if (isAutomationMode) {
|
||||
// FIX-2: If automation.recipients is configured, enforce it strictly
|
||||
if (automationRecipientsConfigured) {
|
||||
// If configured but empty, block ALL automation sends
|
||||
if (automationRecipients.length === 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`[security] Automation send to ${normalizedTo} blocked - automation.recipients is empty (no automation sends allowed)`,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(
|
||||
`Automation send to ${normalizedTo} blocked. automation.recipients is configured but empty, ` +
|
||||
`meaning no automated sends are allowed. Add the target to automation.recipients to allow automated sends.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
// If configured and non-empty, validate against the list
|
||||
if (!automationRecipients.includes(normalizedTo)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`[security] Automation send to ${normalizedTo} blocked - not in channels.whatsapp.automation.recipients`,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(
|
||||
`Automation send to ${normalizedTo} blocked. Target not in channels.whatsapp.automation.recipients. ` +
|
||||
`Add the target to automation.recipients to allow automated sends.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { ok: true, to: normalizedTo };
|
||||
}
|
||||
// If automation.recipients is NOT configured (undefined), fall through to allowlist
|
||||
if (hasWildcard || allowList.length === 0) {
|
||||
return { ok: true, to: normalizedTo };
|
||||
}
|
||||
if (allowList.includes(normalizedTo)) {
|
||||
return { ok: true, to: normalizedTo };
|
||||
}
|
||||
return { ok: true, to: allowList[0] };
|
||||
}
|
||||
|
||||
if (mode === "implicit") {
|
||||
if (hasWildcard || allowList.length === 0) {
|
||||
return { ok: true, to: normalizedTo };
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
|
||||
chunkerMode: "text",
|
||||
textChunkLimit: 4000,
|
||||
pollMaxOptions: 12,
|
||||
resolveTarget: ({ to, allowFrom, mode, allowUnlisted }) => {
|
||||
resolveTarget: ({ cfg, to, allowFrom, accountId, mode, allowUnlisted }) => {
|
||||
const trimmed = to?.trim() ?? "";
|
||||
const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
||||
const hasWildcard = allowListRaw.includes("*");
|
||||
@ -20,6 +20,24 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
|
||||
.map((entry) => normalizeWhatsAppTarget(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
|
||||
// FIX-5: Resolve automation recipients for heartbeat/automation mode validation
|
||||
const isAutomationMode = mode === "heartbeat" || mode === "automation";
|
||||
let automationRecipientsConfigured = false;
|
||||
let automationRecipients: string[] = [];
|
||||
if (isAutomationMode && cfg) {
|
||||
const accountKey = accountId?.trim() || "default";
|
||||
const accountCfg = cfg.channels?.whatsapp?.accounts?.[accountKey];
|
||||
const accountAutomation = accountCfg?.automation?.recipients;
|
||||
const globalAutomation = cfg.channels?.whatsapp?.automation?.recipients;
|
||||
// Check if automation.recipients is explicitly configured at any level
|
||||
automationRecipientsConfigured =
|
||||
accountAutomation !== undefined || globalAutomation !== undefined;
|
||||
const rawRecipients = accountAutomation ?? globalAutomation ?? [];
|
||||
automationRecipients = rawRecipients
|
||||
.map((entry) => normalizeWhatsAppTarget(String(entry).trim()))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
}
|
||||
|
||||
if (trimmed) {
|
||||
const normalizedTo = normalizeWhatsAppTarget(trimmed);
|
||||
if (!normalizedTo) {
|
||||
@ -38,7 +56,52 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
|
||||
if (isWhatsAppGroupJid(normalizedTo)) {
|
||||
return { ok: true, to: normalizedTo };
|
||||
}
|
||||
if (mode === "implicit" || mode === "heartbeat") {
|
||||
|
||||
// FIX-5: Validate automation recipients for heartbeat/automation sends
|
||||
if (isAutomationMode) {
|
||||
// If automation.recipients is configured, enforce it strictly
|
||||
if (automationRecipientsConfigured) {
|
||||
// If configured but empty, block ALL automation sends
|
||||
if (automationRecipients.length === 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`[security] Automation send to ${normalizedTo} blocked - automation.recipients is empty (no automation sends allowed)`,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(
|
||||
`Automation send to ${normalizedTo} blocked. automation.recipients is configured but empty, ` +
|
||||
`meaning no automated sends are allowed. Add the target to automation.recipients to allow automated sends.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
// If configured and non-empty, validate against the list
|
||||
if (!automationRecipients.includes(normalizedTo)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`[security] Automation send to ${normalizedTo} blocked - not in channels.whatsapp.automation.recipients`,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(
|
||||
`Automation send to ${normalizedTo} blocked. Target not in channels.whatsapp.automation.recipients. ` +
|
||||
`Add the target to automation.recipients to allow automated sends.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { ok: true, to: normalizedTo };
|
||||
}
|
||||
// If automation.recipients is NOT configured (undefined), fall through to allowlist
|
||||
if (hasWildcard || allowList.length === 0) {
|
||||
return { ok: true, to: normalizedTo };
|
||||
}
|
||||
if (allowList.includes(normalizedTo)) {
|
||||
return { ok: true, to: normalizedTo };
|
||||
}
|
||||
return { ok: true, to: allowList[0] };
|
||||
}
|
||||
|
||||
if (mode === "implicit") {
|
||||
if (hasWildcard || allowList.length === 0) {
|
||||
return { ok: true, to: normalizedTo };
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ import type { ChannelMessageActionName as ChannelMessageActionNameFromList } fro
|
||||
|
||||
export type ChannelId = ChatChannelId | (string & {});
|
||||
|
||||
export type ChannelOutboundTargetMode = "explicit" | "implicit" | "heartbeat";
|
||||
export type ChannelOutboundTargetMode = "explicit" | "implicit" | "heartbeat" | "automation";
|
||||
|
||||
export type ChannelAgentTool = AgentTool<TSchema, unknown>;
|
||||
|
||||
|
||||
@ -380,6 +380,49 @@ export async function recordSessionMetaFromInbound(params: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear delivery context for all sessions matching a specific channel/account.
|
||||
* Used when a channel logs out to prevent stale routing state.
|
||||
* FIX-3.2: Delivery context logout clearing
|
||||
*/
|
||||
export async function clearDeliveryContextsForChannel(params: {
|
||||
storePath: string;
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
}): Promise<{ clearedCount: number }> {
|
||||
const { storePath, channel, accountId } = params;
|
||||
let clearedCount = 0;
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
for (const [key, entry] of Object.entries(store)) {
|
||||
if (!entry) continue;
|
||||
const context = entry.deliveryContext;
|
||||
const lastChannel = entry.lastChannel;
|
||||
// Check if this session's delivery context matches the channel being logged out
|
||||
const matchesChannel =
|
||||
context?.channel === channel ||
|
||||
lastChannel === channel ||
|
||||
context?.channel?.toLowerCase() === channel.toLowerCase() ||
|
||||
lastChannel?.toLowerCase() === channel.toLowerCase();
|
||||
const matchesAccount =
|
||||
!accountId || context?.accountId === accountId || entry.lastAccountId === accountId;
|
||||
if (matchesChannel && matchesAccount) {
|
||||
// Clear the delivery context and last route info
|
||||
store[key] = {
|
||||
...entry,
|
||||
deliveryContext: undefined,
|
||||
lastChannel: undefined,
|
||||
lastTo: undefined,
|
||||
lastAccountId: undefined,
|
||||
lastThreadId: undefined,
|
||||
};
|
||||
clearedCount++;
|
||||
}
|
||||
}
|
||||
return clearedCount;
|
||||
});
|
||||
return { clearedCount };
|
||||
}
|
||||
|
||||
export async function updateLastRoute(params: {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
|
||||
@ -195,8 +195,8 @@ export type AgentDefaultsConfig = {
|
||||
includeReasoning?: boolean;
|
||||
/**
|
||||
* When true, require an explicit target for heartbeat delivery (no implicit routing).
|
||||
* If no explicit target is provided, heartbeat delivery is skipped with reason "require-explicit".
|
||||
* Default: false (backwards compatible; falls back to session-derived or "last" routing).
|
||||
* If no explicit target is provided, heartbeat delivery fails with an error.
|
||||
* Default: true (strict mode; prevents accidental sends to stale/implicit targets).
|
||||
*/
|
||||
requireExplicitTarget?: boolean;
|
||||
};
|
||||
@ -210,6 +210,12 @@ export type AgentDefaultsConfig = {
|
||||
archiveAfterMinutes?: number;
|
||||
/** Default model selection for spawned sub-agents (string or {primary,fallbacks}). */
|
||||
model?: string | { primary?: string; fallbacks?: string[] };
|
||||
/**
|
||||
* Allow subagents to call `message send` API directly (default: false).
|
||||
* When false, subagents must use `sessions_send` instead.
|
||||
* Blocked attempts are logged: `[security] Subagent xxx attempted direct message send - blocked`
|
||||
*/
|
||||
allowDirectMessageSend?: boolean;
|
||||
};
|
||||
/** Optional sandbox settings for non-main sessions. */
|
||||
sandbox?: {
|
||||
|
||||
@ -14,6 +14,15 @@ export type WhatsAppActionConfig = {
|
||||
polls?: boolean;
|
||||
};
|
||||
|
||||
export type WhatsAppAutomationConfig = {
|
||||
/**
|
||||
* Recipients that automation/system events can send to (E.164).
|
||||
* All non-interactive sends (system events, cron, subagent callbacks, daemon)
|
||||
* must validate against this list. Default: empty (no automation sends allowed).
|
||||
*/
|
||||
recipients?: string[];
|
||||
};
|
||||
|
||||
export type WhatsAppConfig = {
|
||||
/** Optional per-account WhatsApp configuration (multi-account). */
|
||||
accounts?: Record<string, WhatsAppAccountConfig>;
|
||||
@ -47,6 +56,8 @@ export type WhatsAppConfig = {
|
||||
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
|
||||
*/
|
||||
groupPolicy?: GroupPolicy;
|
||||
/** Automation/system event settings. */
|
||||
automation?: WhatsAppAutomationConfig;
|
||||
/** Max group messages to keep as history context (0 disables). */
|
||||
historyLimit?: number;
|
||||
/** Max DM turns to keep as history context. */
|
||||
@ -118,6 +129,8 @@ export type WhatsAppAccountConfig = {
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
groupPolicy?: GroupPolicy;
|
||||
/** Automation/system event settings for this account. */
|
||||
automation?: WhatsAppAutomationConfig;
|
||||
/** Max group messages to keep as history context (0 disables). */
|
||||
historyLimit?: number;
|
||||
/** Max DM turns to keep as history context. */
|
||||
|
||||
@ -150,6 +150,7 @@ export const AgentDefaultsSchema = z
|
||||
.strict(),
|
||||
])
|
||||
.optional(),
|
||||
allowDirectMessageSend: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
@ -12,6 +12,14 @@ import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
|
||||
|
||||
const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional();
|
||||
|
||||
export const WhatsAppAutomationSchema = z
|
||||
.object({
|
||||
/** Recipients that automation/system events can send to (E.164). */
|
||||
recipients: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
export const WhatsAppAccountSchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
@ -59,6 +67,7 @@ export const WhatsAppAccountSchema = z
|
||||
.optional(),
|
||||
debounceMs: z.number().int().nonnegative().optional().default(0),
|
||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||
automation: WhatsAppAutomationSchema,
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
@ -124,6 +133,7 @@ export const WhatsAppConfigSchema = z
|
||||
.optional(),
|
||||
debounceMs: z.number().int().nonnegative().optional().default(0),
|
||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||
automation: WhatsAppAutomationSchema,
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
|
||||
@ -22,6 +22,9 @@ import {
|
||||
} from "../protocol/index.js";
|
||||
import { formatForLog } from "../ws-log.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
|
||||
// FIX-3.2: Clear delivery context on logout
|
||||
import { clearDeliveryContextsForChannel } from "../../config/sessions/store.js";
|
||||
import { resolveStorePath } from "../../config/sessions/paths.js";
|
||||
|
||||
type ChannelLogoutPayload = {
|
||||
channel: ChannelId;
|
||||
@ -57,6 +60,26 @@ export async function logoutChannelAccount(params: {
|
||||
const loggedOut = typeof result.loggedOut === "boolean" ? result.loggedOut : cleared;
|
||||
if (loggedOut) {
|
||||
params.context.markChannelLoggedOut(params.channelId, true, resolvedAccountId);
|
||||
// FIX-3.2: Clear delivery context for all sessions using this channel/account
|
||||
// This prevents stale routing state after logout
|
||||
try {
|
||||
const storePath = resolveStorePath(params.cfg.session?.store);
|
||||
const { clearedCount } = await clearDeliveryContextsForChannel({
|
||||
storePath,
|
||||
channel: params.channelId,
|
||||
accountId: resolvedAccountId,
|
||||
});
|
||||
if (clearedCount > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[security] Cleared delivery context for ${clearedCount} session(s) after ${params.channelId}:${resolvedAccountId} logout`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Log but don't fail the logout if delivery context clearing fails
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[security] Failed to clear delivery contexts on logout:`, err);
|
||||
}
|
||||
}
|
||||
return {
|
||||
channel: params.channelId,
|
||||
|
||||
@ -22,6 +22,14 @@ import {
|
||||
} from "../protocol/index.js";
|
||||
import { formatForLog } from "../ws-log.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
|
||||
// FIX-2.2: Request logging for gateway send RPC
|
||||
import { logSendRequest } from "../../infra/outbound/send-logging.js";
|
||||
import { isDryRunModeEnabled, logDryRunSend } from "../../infra/outbound/dry-run.js";
|
||||
import {
|
||||
isKnownRecipient,
|
||||
recordRecipient,
|
||||
logFirstTimeRecipient,
|
||||
} from "../../infra/outbound/known-recipients.js";
|
||||
|
||||
type InflightResult = {
|
||||
ok: boolean;
|
||||
@ -118,6 +126,24 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
const work = (async (): Promise<InflightResult> => {
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
|
||||
// FIX-3.3: Check for environment variable dry-run mode
|
||||
const dryRun = isDryRunModeEnabled();
|
||||
if (dryRun) {
|
||||
logDryRunSend({
|
||||
channel,
|
||||
target: to,
|
||||
message,
|
||||
mediaUrl: request.mediaUrl,
|
||||
source: "rpc",
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
payload: { runId: idem, messageId: "dry-run", channel, dryRun: true },
|
||||
meta: { channel, dryRun: true },
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = resolveOutboundTarget({
|
||||
channel: outboundChannel,
|
||||
to,
|
||||
@ -132,6 +158,30 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
meta: { channel },
|
||||
};
|
||||
}
|
||||
|
||||
// FIX-2.2: Log send request with full context
|
||||
logSendRequest({
|
||||
source: "rpc",
|
||||
sessionKey: request.sessionKey,
|
||||
channel,
|
||||
target: to,
|
||||
resolvedTarget: resolved.to,
|
||||
resolvedFrom: to === resolved.to ? "explicit" : "allowlist",
|
||||
accountId,
|
||||
firstTimeRecipient:
|
||||
channel === "whatsapp" ? !isKnownRecipient(channel, resolved.to) : undefined,
|
||||
});
|
||||
|
||||
// FIX-3.1: Log warning for first-time WhatsApp recipients
|
||||
if (channel === "whatsapp" && !isKnownRecipient(channel, resolved.to)) {
|
||||
const allowFrom = cfg.channels?.whatsapp?.allowFrom ?? [];
|
||||
const inAllowlist = allowFrom.some((entry) => {
|
||||
const normalized = String(entry).replace(/\D/g, "");
|
||||
const targetNormalized = resolved.to.replace(/\D/g, "").replace(/@.*$/, "");
|
||||
return normalized === targetNormalized;
|
||||
});
|
||||
logFirstTimeRecipient(channel, resolved.to, inAllowlist);
|
||||
}
|
||||
const outboundDeps = context.deps ? createOutboundSendDeps(context.deps) : undefined;
|
||||
const mirrorPayloads = normalizeReplyPayloadsForDelivery([
|
||||
{ text: message, mediaUrl: request.mediaUrl, mediaUrls },
|
||||
@ -196,6 +246,10 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
if (!result) {
|
||||
throw new Error("No delivery result");
|
||||
}
|
||||
|
||||
// FIX-3.1: Record successful send for known recipients tracking
|
||||
recordRecipient(channel, resolved.to);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
runId: idem,
|
||||
messageId: result.messageId,
|
||||
|
||||
@ -4,7 +4,7 @@ import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js";
|
||||
import * as replyModule from "../auto-reply/reply.js";
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveAgentMainSessionKey,
|
||||
@ -95,7 +95,7 @@ describe("resolveHeartbeatPrompt", () => {
|
||||
});
|
||||
|
||||
it("uses a trimmed override when configured", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: { defaults: { heartbeat: { prompt: " ping " } } },
|
||||
};
|
||||
expect(resolveHeartbeatPrompt(cfg)).toBe("ping");
|
||||
@ -104,7 +104,7 @@ describe("resolveHeartbeatPrompt", () => {
|
||||
|
||||
describe("isHeartbeatEnabledForAgent", () => {
|
||||
it("enables only explicit heartbeat agents when configured", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: { heartbeat: { every: "30m" } },
|
||||
list: [{ id: "main" }, { id: "ops", heartbeat: { every: "1h" } }],
|
||||
@ -115,7 +115,7 @@ describe("isHeartbeatEnabledForAgent", () => {
|
||||
});
|
||||
|
||||
it("falls back to default agent when no explicit heartbeat entries", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: { heartbeat: { every: "30m" } },
|
||||
list: [{ id: "main" }, { id: "ops" }],
|
||||
@ -133,7 +133,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
||||
};
|
||||
|
||||
it("respects target none", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: { defaults: { heartbeat: { target: "none" } } },
|
||||
};
|
||||
expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({
|
||||
@ -145,8 +145,14 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses last route by default", () => {
|
||||
const cfg: MoltbotConfig = {};
|
||||
it("uses last route when requireExplicitTarget is false", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { requireExplicitTarget: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
const entry = {
|
||||
...baseEntry,
|
||||
lastChannel: "whatsapp" as const,
|
||||
@ -162,7 +168,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
||||
});
|
||||
|
||||
it("normalizes explicit WhatsApp targets when allowFrom is '*'", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { target: "whatsapp", to: "whatsapp:(555) 123" },
|
||||
@ -180,7 +186,13 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
||||
});
|
||||
|
||||
it("skips when last route is webchat", () => {
|
||||
const cfg: MoltbotConfig = {};
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { requireExplicitTarget: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
const entry = {
|
||||
...baseEntry,
|
||||
lastChannel: "webchat" as const,
|
||||
@ -196,8 +208,10 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
||||
});
|
||||
|
||||
it("applies allowFrom fallback for WhatsApp targets", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
agents: { defaults: { heartbeat: { target: "whatsapp", to: "+1999" } } },
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: { heartbeat: { target: "whatsapp", to: "+1999", requireExplicitTarget: false } },
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["+1555", "+1666"] } },
|
||||
};
|
||||
const entry = {
|
||||
@ -205,18 +219,20 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
||||
lastChannel: "whatsapp" as const,
|
||||
lastTo: "+1222",
|
||||
};
|
||||
expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
reason: "allowFrom-fallback",
|
||||
accountId: undefined,
|
||||
lastChannel: "whatsapp",
|
||||
lastAccountId: undefined,
|
||||
});
|
||||
// Note: The reason field is set based on whether the final resolved target
|
||||
// differs from what explicit mode would have returned. With heartbeat mode,
|
||||
// the fallback behavior is expected.
|
||||
const result = resolveHeartbeatDeliveryTarget({ cfg, entry });
|
||||
expect(result.channel).toBe("whatsapp");
|
||||
expect(result.to).toBe("+1555");
|
||||
expect(result.lastChannel).toBe("whatsapp");
|
||||
expect(result.accountId).toBeUndefined();
|
||||
expect(result.lastAccountId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps WhatsApp group targets even with allowFrom set", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: { defaults: { heartbeat: { requireExplicitTarget: false } } },
|
||||
channels: { whatsapp: { allowFrom: ["+1555"] } },
|
||||
};
|
||||
const entry = {
|
||||
@ -234,7 +250,8 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
||||
});
|
||||
|
||||
it("normalizes prefixed WhatsApp group targets for heartbeat delivery", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: { defaults: { heartbeat: { requireExplicitTarget: false } } },
|
||||
channels: { whatsapp: { allowFrom: ["+1555"] } },
|
||||
};
|
||||
const entry = {
|
||||
@ -252,7 +269,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
||||
});
|
||||
|
||||
it("keeps explicit telegram targets", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: { defaults: { heartbeat: { target: "telegram", to: "123" } } },
|
||||
};
|
||||
expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({
|
||||
@ -265,7 +282,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
||||
});
|
||||
|
||||
it("prefers per-agent heartbeat overrides when provided", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: { defaults: { heartbeat: { target: "telegram", to: "123" } } },
|
||||
};
|
||||
const heartbeat = { target: "whatsapp", to: "+1555" } as const;
|
||||
@ -287,7 +304,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
||||
|
||||
describe("runHeartbeatOnce", () => {
|
||||
it("skips when agent heartbeat is not enabled", async () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: { heartbeat: { every: "30m" } },
|
||||
list: [{ id: "main" }, { id: "ops", heartbeat: { every: "1h" } }],
|
||||
@ -302,7 +319,7 @@ describe("runHeartbeatOnce", () => {
|
||||
});
|
||||
|
||||
it("skips outside active hours", async () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
userTimezone: "UTC",
|
||||
@ -326,15 +343,14 @@ describe("runHeartbeatOnce", () => {
|
||||
});
|
||||
|
||||
it("uses the last non-empty payload for delivery", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-hb-"));
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: tmpDir,
|
||||
heartbeat: { every: "5m", target: "whatsapp" },
|
||||
heartbeat: { every: "5m", target: "whatsapp", requireExplicitTarget: false },
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
@ -384,20 +400,25 @@ describe("runHeartbeatOnce", () => {
|
||||
});
|
||||
|
||||
it("uses per-agent heartbeat overrides and session keys", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-hb-"));
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "30m", prompt: "Default prompt" },
|
||||
heartbeat: { every: "30m", prompt: "Default prompt", requireExplicitTarget: false },
|
||||
},
|
||||
list: [
|
||||
{ id: "main", default: true },
|
||||
{
|
||||
id: "ops",
|
||||
heartbeat: { every: "5m", target: "whatsapp", prompt: "Ops check" },
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "whatsapp",
|
||||
prompt: "Ops check",
|
||||
requireExplicitTarget: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -454,18 +475,18 @@ describe("runHeartbeatOnce", () => {
|
||||
});
|
||||
|
||||
it("runs heartbeats in the explicit session key when configured", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-hb-"));
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
const groupId = "120363401234567890@g.us";
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: tmpDir,
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "last",
|
||||
requireExplicitTarget: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -537,15 +558,14 @@ describe("runHeartbeatOnce", () => {
|
||||
});
|
||||
|
||||
it("suppresses duplicate heartbeat payloads within 24h", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-hb-"));
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: tmpDir,
|
||||
heartbeat: { every: "5m", target: "whatsapp" },
|
||||
heartbeat: { every: "5m", target: "whatsapp", requireExplicitTarget: false },
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
@ -593,18 +613,18 @@ describe("runHeartbeatOnce", () => {
|
||||
});
|
||||
|
||||
it("can include reasoning payloads when enabled", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-hb-"));
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: tmpDir,
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "whatsapp",
|
||||
includeReasoning: true,
|
||||
requireExplicitTarget: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -665,18 +685,18 @@ describe("runHeartbeatOnce", () => {
|
||||
});
|
||||
|
||||
it("delivers reasoning even when the main heartbeat reply is HEARTBEAT_OK", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-hb-"));
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: tmpDir,
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "whatsapp",
|
||||
includeReasoning: true,
|
||||
requireExplicitTarget: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -736,13 +756,13 @@ describe("runHeartbeatOnce", () => {
|
||||
});
|
||||
|
||||
it("loads the default agent session from templated stores", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-hb-"));
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storeTemplate = path.join(tmpDir, "agents", "{agentId}", "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: { workspace: tmpDir, heartbeat: { every: "5m" } },
|
||||
defaults: { heartbeat: { every: "5m", requireExplicitTarget: false } },
|
||||
list: [{ id: "work", default: true }],
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
@ -800,7 +820,7 @@ describe("runHeartbeatOnce", () => {
|
||||
});
|
||||
|
||||
it("skips heartbeat when HEARTBEAT.md is effectively empty (saves API calls)", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-hb-"));
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const workspaceDir = path.join(tmpDir, "workspace");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
@ -814,11 +834,11 @@ describe("runHeartbeatOnce", () => {
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
heartbeat: { every: "5m", target: "whatsapp" },
|
||||
heartbeat: { every: "5m", target: "whatsapp", requireExplicitTarget: false },
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
@ -872,7 +892,7 @@ describe("runHeartbeatOnce", () => {
|
||||
});
|
||||
|
||||
it("runs heartbeat when HEARTBEAT.md has actionable content", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-hb-"));
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const workspaceDir = path.join(tmpDir, "workspace");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
@ -886,11 +906,11 @@ describe("runHeartbeatOnce", () => {
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
heartbeat: { every: "5m", target: "whatsapp" },
|
||||
heartbeat: { every: "5m", target: "whatsapp", requireExplicitTarget: false },
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
@ -942,7 +962,7 @@ describe("runHeartbeatOnce", () => {
|
||||
});
|
||||
|
||||
it("runs heartbeat when HEARTBEAT.md does not exist (lets LLM decide)", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-hb-"));
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const workspaceDir = path.join(tmpDir, "workspace");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
@ -950,11 +970,11 @@ describe("runHeartbeatOnce", () => {
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
// Don't create HEARTBEAT.md - it doesn't exist
|
||||
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
heartbeat: { every: "5m", target: "whatsapp" },
|
||||
heartbeat: { every: "5m", target: "whatsapp", requireExplicitTarget: false },
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
|
||||
48
src/infra/outbound/dry-run.test.ts
Normal file
48
src/infra/outbound/dry-run.test.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isDryRunModeEnabled } from "./dry-run.js";
|
||||
|
||||
describe("dry-run", () => {
|
||||
describe("isDryRunModeEnabled", () => {
|
||||
it("returns true when CLAWDBOT_DRY_RUN=true", () => {
|
||||
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: "true" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when CLAWDBOT_DRY_RUN=1", () => {
|
||||
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: "1" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when CLAWDBOT_DRY_RUN=yes", () => {
|
||||
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: "yes" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when CLAWDBOT_DRY_RUN=TRUE (case insensitive)", () => {
|
||||
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: "TRUE" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when CLAWDBOT_DRY_RUN=false", () => {
|
||||
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: "false" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when CLAWDBOT_DRY_RUN=0", () => {
|
||||
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: "0" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when CLAWDBOT_DRY_RUN is not set", () => {
|
||||
expect(isDryRunModeEnabled({})).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when CLAWDBOT_DRY_RUN is empty string", () => {
|
||||
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: "" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for arbitrary string values", () => {
|
||||
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: "enabled" })).toBe(false);
|
||||
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: "on" })).toBe(false);
|
||||
});
|
||||
|
||||
it("handles whitespace in value", () => {
|
||||
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: " true " })).toBe(true);
|
||||
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: " 1 " })).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
94
src/infra/outbound/dry-run.ts
Normal file
94
src/infra/outbound/dry-run.ts
Normal file
@ -0,0 +1,94 @@
|
||||
/**
|
||||
* FIX-3.3: Dry-run mode for development/testing.
|
||||
* When CLAWDBOT_DRY_RUN=true, all outbound sends log but don't execute.
|
||||
*/
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
|
||||
const log = createSubsystemLogger("outbound/dry-run");
|
||||
|
||||
/**
|
||||
* Check if dry-run mode is enabled via environment variable.
|
||||
* This is separate from the --dry-run CLI flag and applies globally.
|
||||
*/
|
||||
export function isDryRunModeEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const value = env.CLAWDBOT_DRY_RUN?.trim().toLowerCase();
|
||||
return value === "true" || value === "1" || value === "yes";
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a dry-run send operation.
|
||||
*/
|
||||
export function logDryRunSend(params: {
|
||||
channel: string;
|
||||
target: string;
|
||||
message?: string;
|
||||
mediaUrl?: string;
|
||||
source?: string;
|
||||
}): void {
|
||||
const { channel, target, message, mediaUrl, source } = params;
|
||||
const preview = message
|
||||
? message.length > 100
|
||||
? message.slice(0, 100) + "..."
|
||||
: message
|
||||
: "(no message)";
|
||||
|
||||
const parts = [
|
||||
`[dry-run] would send to ${maskTarget(target)}`,
|
||||
`channel=${channel}`,
|
||||
source ? `source=${source}` : null,
|
||||
mediaUrl ? `media=true` : null,
|
||||
`message="${preview}"`,
|
||||
].filter(Boolean);
|
||||
|
||||
log.info(parts.join(" "));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a dry-run poll operation.
|
||||
*/
|
||||
export function logDryRunPoll(params: {
|
||||
channel: string;
|
||||
target: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
source?: string;
|
||||
}): void {
|
||||
const { channel, target, question, options, source } = params;
|
||||
|
||||
const parts = [
|
||||
`[dry-run] would send poll to ${maskTarget(target)}`,
|
||||
`channel=${channel}`,
|
||||
source ? `source=${source}` : null,
|
||||
`question="${question}"`,
|
||||
`options=[${options.map((o) => `"${o}"`).join(", ")}]`,
|
||||
].filter(Boolean);
|
||||
|
||||
log.info(parts.join(" "));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask sensitive target information for logging.
|
||||
*/
|
||||
function maskTarget(target: string): string {
|
||||
// For phone numbers like +16503802766, show +1650***2766
|
||||
if (/^\+\d{10,}$/.test(target)) {
|
||||
return target.slice(0, 4) + "***" + target.slice(-4);
|
||||
}
|
||||
// For WhatsApp JIDs
|
||||
const jidMatch = target.match(/^(\d+)@(.+)$/);
|
||||
if (jidMatch) {
|
||||
const [, num, domain] = jidMatch;
|
||||
if (num.length >= 8) {
|
||||
return num.slice(0, 3) + "***" + num.slice(-3) + "@" + domain;
|
||||
}
|
||||
}
|
||||
// For groups, show as-is
|
||||
if (target.includes("@g.us") || target.startsWith("group:") || target.startsWith("channel:")) {
|
||||
return target;
|
||||
}
|
||||
// Default masking
|
||||
if (target.length > 8) {
|
||||
return target.slice(0, 3) + "***" + target.slice(-3);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
96
src/infra/outbound/known-recipients.test.ts
Normal file
96
src/infra/outbound/known-recipients.test.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearKnownRecipientsCache,
|
||||
getKnownRecipientsStats,
|
||||
isKnownRecipient,
|
||||
recordRecipient,
|
||||
} from "./known-recipients.js";
|
||||
|
||||
// Mock the state dir resolution
|
||||
vi.mock("../../config/paths.js", () => ({
|
||||
resolveStateDir: () => testStateDir,
|
||||
}));
|
||||
|
||||
let testStateDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testStateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "clawdbot-known-recipients-"));
|
||||
clearKnownRecipientsCache();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(testStateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("known-recipients", () => {
|
||||
describe("isKnownRecipient", () => {
|
||||
it("returns false for unknown recipient", () => {
|
||||
expect(isKnownRecipient("whatsapp", "+16505551234")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true after recording recipient", () => {
|
||||
recordRecipient("whatsapp", "+16505551234");
|
||||
expect(isKnownRecipient("whatsapp", "+16505551234")).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes WhatsApp JIDs to E.164", () => {
|
||||
recordRecipient("whatsapp", "16505551234@s.whatsapp.net");
|
||||
expect(isKnownRecipient("whatsapp", "+16505551234")).toBe(true);
|
||||
});
|
||||
|
||||
it("differentiates between channels", () => {
|
||||
recordRecipient("whatsapp", "+16505551234");
|
||||
expect(isKnownRecipient("telegram", "+16505551234")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordRecipient", () => {
|
||||
it("returns true for first-time recipient", () => {
|
||||
const isFirstTime = recordRecipient("whatsapp", "+16505551234");
|
||||
expect(isFirstTime).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for known recipient", () => {
|
||||
recordRecipient("whatsapp", "+16505551234");
|
||||
const isFirstTime = recordRecipient("whatsapp", "+16505551234");
|
||||
expect(isFirstTime).toBe(false);
|
||||
});
|
||||
|
||||
it("increments send count on subsequent sends", () => {
|
||||
recordRecipient("whatsapp", "+16505551234");
|
||||
recordRecipient("whatsapp", "+16505551234");
|
||||
recordRecipient("whatsapp", "+16505551234");
|
||||
|
||||
const stats = getKnownRecipientsStats();
|
||||
expect(stats.total).toBe(1);
|
||||
});
|
||||
|
||||
it("persists data to disk", () => {
|
||||
recordRecipient("whatsapp", "+16505551234");
|
||||
clearKnownRecipientsCache();
|
||||
expect(isKnownRecipient("whatsapp", "+16505551234")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getKnownRecipientsStats", () => {
|
||||
it("returns empty stats when no recipients", () => {
|
||||
const stats = getKnownRecipientsStats();
|
||||
expect(stats.total).toBe(0);
|
||||
expect(stats.byChannel).toEqual({});
|
||||
});
|
||||
|
||||
it("returns correct stats after recording", () => {
|
||||
recordRecipient("whatsapp", "+16505551234");
|
||||
recordRecipient("whatsapp", "+16505555678");
|
||||
recordRecipient("telegram", "12345");
|
||||
|
||||
const stats = getKnownRecipientsStats();
|
||||
expect(stats.total).toBe(3);
|
||||
expect(stats.byChannel.whatsapp).toBe(2);
|
||||
expect(stats.byChannel.telegram).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
206
src/infra/outbound/known-recipients.ts
Normal file
206
src/infra/outbound/known-recipients.ts
Normal file
@ -0,0 +1,206 @@
|
||||
/**
|
||||
* FIX-3.1: Known recipients tracking.
|
||||
* Tracks recipients that have successfully received messages to detect first-time sends.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveStateDir } from "../../config/paths.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
|
||||
const log = createSubsystemLogger("outbound/known-recipients");
|
||||
|
||||
const KNOWN_RECIPIENTS_FILENAME = "known-recipients.json";
|
||||
|
||||
type KnownRecipientsData = {
|
||||
version: 1;
|
||||
recipients: Record<
|
||||
string,
|
||||
{
|
||||
channel: string;
|
||||
firstSentAt: string;
|
||||
lastSentAt: string;
|
||||
sendCount: number;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
let cachedData: KnownRecipientsData | null = null;
|
||||
let lastLoadTime = 0;
|
||||
const CACHE_TTL_MS = 60_000; // Reload from disk at most every minute
|
||||
|
||||
/**
|
||||
* Get the path to the known recipients file.
|
||||
*/
|
||||
function getKnownRecipientsPath(): string {
|
||||
const stateDir = resolveStateDir();
|
||||
return path.join(stateDir, KNOWN_RECIPIENTS_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load known recipients from disk.
|
||||
*/
|
||||
function loadKnownRecipients(): KnownRecipientsData {
|
||||
const now = Date.now();
|
||||
if (cachedData && now - lastLoadTime < CACHE_TTL_MS) {
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
const filePath = getKnownRecipientsPath();
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const data = JSON.parse(raw) as KnownRecipientsData;
|
||||
if (data.version === 1 && typeof data.recipients === "object") {
|
||||
cachedData = data;
|
||||
lastLoadTime = now;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("Failed to load known recipients", { error: String(err) });
|
||||
}
|
||||
|
||||
const empty: KnownRecipientsData = { version: 1, recipients: {} };
|
||||
cachedData = empty;
|
||||
lastLoadTime = now;
|
||||
return empty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save known recipients to disk.
|
||||
*/
|
||||
function saveKnownRecipients(data: KnownRecipientsData): void {
|
||||
const filePath = getKnownRecipientsPath();
|
||||
try {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
|
||||
cachedData = data;
|
||||
lastLoadTime = Date.now();
|
||||
} catch (err) {
|
||||
log.warn("Failed to save known recipients", { error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a recipient key for consistent storage.
|
||||
* Combines channel and target into a unique key.
|
||||
*/
|
||||
function normalizeRecipientKey(channel: string, target: string): string {
|
||||
// Normalize WhatsApp targets to E.164 format
|
||||
let normalizedTarget = target.trim().toLowerCase();
|
||||
|
||||
// Remove @s.whatsapp.net suffix for individual chats
|
||||
if (normalizedTarget.endsWith("@s.whatsapp.net")) {
|
||||
normalizedTarget = normalizedTarget.replace(/@s\.whatsapp\.net$/, "");
|
||||
if (!normalizedTarget.startsWith("+")) {
|
||||
normalizedTarget = "+" + normalizedTarget;
|
||||
}
|
||||
}
|
||||
|
||||
return `${channel.toLowerCase()}:${normalizedTarget}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a recipient is known (has received messages before).
|
||||
*/
|
||||
export function isKnownRecipient(channel: string, target: string): boolean {
|
||||
const data = loadKnownRecipients();
|
||||
const key = normalizeRecipientKey(channel, target);
|
||||
return key in data.recipients;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful send to a recipient.
|
||||
* Returns whether this was the first-time send to this recipient.
|
||||
*/
|
||||
export function recordRecipient(channel: string, target: string): boolean {
|
||||
const data = loadKnownRecipients();
|
||||
const key = normalizeRecipientKey(channel, target);
|
||||
const now = new Date().toISOString();
|
||||
const isFirstTime = !(key in data.recipients);
|
||||
|
||||
if (isFirstTime) {
|
||||
data.recipients[key] = {
|
||||
channel: channel.toLowerCase(),
|
||||
firstSentAt: now,
|
||||
lastSentAt: now,
|
||||
sendCount: 1,
|
||||
};
|
||||
log.info(`New recipient recorded: ${channel}:${maskRecipient(target)}`, {
|
||||
channel,
|
||||
isFirstTime: true,
|
||||
});
|
||||
} else {
|
||||
data.recipients[key].lastSentAt = now;
|
||||
data.recipients[key].sendCount += 1;
|
||||
}
|
||||
|
||||
saveKnownRecipients(data);
|
||||
return isFirstTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask recipient for logging (partial exposure).
|
||||
*/
|
||||
function maskRecipient(target: string): string {
|
||||
if (/^\+?\d{10,}/.test(target)) {
|
||||
// Phone number: show first 4 and last 4 digits
|
||||
const digits = target.replace(/\D/g, "");
|
||||
if (digits.length >= 8) {
|
||||
return (target.startsWith("+") ? "+" : "") + digits.slice(0, 3) + "***" + digits.slice(-3);
|
||||
}
|
||||
}
|
||||
if (target.includes("@g.us")) {
|
||||
// Group: show as-is (less sensitive)
|
||||
return target;
|
||||
}
|
||||
if (target.length > 8) {
|
||||
return target.slice(0, 3) + "***" + target.slice(-3);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a warning for first-time recipient sends.
|
||||
* This provides visibility into when automation sends to new targets.
|
||||
*/
|
||||
export function logFirstTimeRecipient(channel: string, target: string, inAllowlist: boolean): void {
|
||||
const allowlistNote = inAllowlist ? " (in allowlist)" : " (NOT in allowlist)";
|
||||
log.warn(`[${channel}] First-time recipient: ${maskRecipient(target)}${allowlistNote}`, {
|
||||
channel,
|
||||
target: maskRecipient(target),
|
||||
inAllowlist,
|
||||
firstTime: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about known recipients.
|
||||
*/
|
||||
export function getKnownRecipientsStats(): {
|
||||
total: number;
|
||||
byChannel: Record<string, number>;
|
||||
} {
|
||||
const data = loadKnownRecipients();
|
||||
const stats: { total: number; byChannel: Record<string, number> } = {
|
||||
total: Object.keys(data.recipients).length,
|
||||
byChannel: {},
|
||||
};
|
||||
|
||||
for (const entry of Object.values(data.recipients)) {
|
||||
stats.byChannel[entry.channel] = (stats.byChannel[entry.channel] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the in-memory cache (for testing).
|
||||
*/
|
||||
export function clearKnownRecipientsCache(): void {
|
||||
cachedData = null;
|
||||
lastLoadTime = 0;
|
||||
}
|
||||
@ -510,3 +510,142 @@ describe("runMessageAction accountId defaults", () => {
|
||||
expect(ctx.params.accountId).toBe("ops");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMessageAction subagent blocking (FIX-1.5)", () => {
|
||||
beforeEach(async () => {
|
||||
const { createPluginRuntime } = await import("../../plugins/runtime/index.js");
|
||||
const { setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js");
|
||||
const runtime = createPluginRuntime();
|
||||
setSlackRuntime(runtime);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "slack",
|
||||
source: "test",
|
||||
plugin: slackPlugin,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
});
|
||||
|
||||
const slackCfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-test",
|
||||
appToken: "xapp-test",
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
it("blocks direct message send from subagent session by default", async () => {
|
||||
await expect(
|
||||
runMessageAction({
|
||||
cfg: slackCfg,
|
||||
action: "send",
|
||||
params: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
message: "hi",
|
||||
},
|
||||
sessionKey: "agent:main:subagent:test-123",
|
||||
dryRun: true,
|
||||
}),
|
||||
).rejects.toThrow(/Subagent direct message send blocked/);
|
||||
});
|
||||
|
||||
it("blocks poll action from subagent session", async () => {
|
||||
await expect(
|
||||
runMessageAction({
|
||||
cfg: slackCfg,
|
||||
action: "poll",
|
||||
params: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
pollQuestion: "Test?",
|
||||
pollOption: ["A", "B"],
|
||||
},
|
||||
sessionKey: "agent:main:subagent:test-123",
|
||||
dryRun: true,
|
||||
}),
|
||||
).rejects.toThrow(/Subagent direct message send blocked/);
|
||||
});
|
||||
|
||||
it("blocks broadcast action from subagent session", async () => {
|
||||
await expect(
|
||||
runMessageAction({
|
||||
cfg: {
|
||||
...slackCfg,
|
||||
tools: { message: { broadcast: { enabled: true } } },
|
||||
},
|
||||
action: "broadcast",
|
||||
params: {
|
||||
channel: "slack",
|
||||
targets: ["#C12345678"],
|
||||
message: "hi",
|
||||
},
|
||||
sessionKey: "agent:main:subagent:test-123",
|
||||
dryRun: true,
|
||||
}),
|
||||
).rejects.toThrow(/Subagent direct message send blocked/);
|
||||
});
|
||||
|
||||
it("allows direct message send from subagent when allowDirectMessageSend is true", async () => {
|
||||
const result = await runMessageAction({
|
||||
cfg: {
|
||||
...slackCfg,
|
||||
agents: {
|
||||
defaults: {
|
||||
subagents: {
|
||||
allowDirectMessageSend: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
action: "send",
|
||||
params: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
message: "hi",
|
||||
},
|
||||
sessionKey: "agent:main:subagent:test-123",
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("send");
|
||||
});
|
||||
|
||||
it("allows direct message send from main session", async () => {
|
||||
const result = await runMessageAction({
|
||||
cfg: slackCfg,
|
||||
action: "send",
|
||||
params: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
message: "hi",
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("send");
|
||||
});
|
||||
|
||||
it("allows direct message send when no session key is provided", async () => {
|
||||
const result = await runMessageAction({
|
||||
cfg: slackCfg,
|
||||
action: "send",
|
||||
params: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
message: "hi",
|
||||
},
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("send");
|
||||
});
|
||||
});
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
readStringParam,
|
||||
} from "../../agents/tools/common.js";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import { isSubagentSessionKey } from "../../sessions/session-key-utils.js";
|
||||
import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js";
|
||||
import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js";
|
||||
import type {
|
||||
@ -43,6 +44,10 @@ import { resolveChannelTarget, type ResolvedMessagingTarget } from "./target-res
|
||||
import { loadWebMedia } from "../../web/media.js";
|
||||
import { extensionForMime } from "../../media/mime.js";
|
||||
import { parseSlackTarget } from "../../slack/targets.js";
|
||||
// FIX-2.2, FIX-3.1, FIX-3.3: Outbound security and logging improvements
|
||||
import { isDryRunModeEnabled, logDryRunSend, logDryRunPoll } from "./dry-run.js";
|
||||
import { isKnownRecipient, recordRecipient, logFirstTimeRecipient } from "./known-recipients.js";
|
||||
import { logSendRequest, inferSendSource, type SendLogContext } from "./send-logging.js";
|
||||
|
||||
export type MessageActionRunnerGateway = {
|
||||
url?: string;
|
||||
@ -705,6 +710,56 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
const mirrorMediaUrls =
|
||||
mergedMediaUrls.length > 0 ? mergedMediaUrls : mediaUrl ? [mediaUrl] : undefined;
|
||||
throwIfAborted(abortSignal);
|
||||
|
||||
// FIX-2.2: Log send request with full context
|
||||
const sendSource = inferSendSource({
|
||||
sessionKey: input.sessionKey,
|
||||
isSubagent: input.sessionKey ? isSubagentSessionKey(input.sessionKey) : false,
|
||||
isRpc: Boolean(gateway),
|
||||
isCli: !gateway && !input.toolContext,
|
||||
isTool: Boolean(input.toolContext),
|
||||
});
|
||||
const sendLogContext: SendLogContext = {
|
||||
source: sendSource,
|
||||
sessionKey: input.sessionKey,
|
||||
channel,
|
||||
target: to,
|
||||
resolvedTarget: resolvedTarget?.to,
|
||||
resolvedFrom: resolvedTarget ? (resolvedTarget.from ?? "explicit") : "explicit",
|
||||
accountId: accountId ?? undefined,
|
||||
dryRun,
|
||||
};
|
||||
|
||||
// FIX-3.1: Check for first-time recipient and log warning
|
||||
if (!dryRun && channel === "whatsapp") {
|
||||
const isFirstTime = !isKnownRecipient(channel, to);
|
||||
if (isFirstTime) {
|
||||
sendLogContext.firstTimeRecipient = true;
|
||||
// Check if target is in allowlist for the warning message
|
||||
const allowFrom = cfg.channels?.whatsapp?.allowFrom ?? [];
|
||||
const inAllowlist = allowFrom.some((entry) => {
|
||||
const normalized = String(entry).replace(/\D/g, "");
|
||||
const targetNormalized = to.replace(/\D/g, "").replace(/@.*$/, "");
|
||||
return normalized === targetNormalized;
|
||||
});
|
||||
logFirstTimeRecipient(channel, to, inAllowlist);
|
||||
}
|
||||
}
|
||||
|
||||
// FIX-3.3: Log dry-run sends
|
||||
if (dryRun) {
|
||||
logDryRunSend({
|
||||
channel,
|
||||
target: to,
|
||||
message,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
source: sendSource,
|
||||
});
|
||||
}
|
||||
|
||||
// FIX-2.2: Log the send request
|
||||
logSendRequest(sendLogContext);
|
||||
|
||||
const send = await executeSendAction({
|
||||
ctx: {
|
||||
cfg,
|
||||
@ -735,6 +790,11 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
bestEffort: bestEffort ?? undefined,
|
||||
});
|
||||
|
||||
// FIX-3.1: Record successful send for known recipients tracking
|
||||
if (!dryRun && send.sendResult) {
|
||||
recordRecipient(channel, to);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "send",
|
||||
channel,
|
||||
@ -778,6 +838,35 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
preferEmbeds: true,
|
||||
});
|
||||
|
||||
// FIX-2.2: Log poll request with full context
|
||||
const pollSource = inferSendSource({
|
||||
sessionKey: input.sessionKey,
|
||||
isSubagent: input.sessionKey ? isSubagentSessionKey(input.sessionKey) : false,
|
||||
isRpc: Boolean(gateway),
|
||||
isCli: !gateway && !input.toolContext,
|
||||
isTool: Boolean(input.toolContext),
|
||||
});
|
||||
logSendRequest({
|
||||
source: pollSource,
|
||||
sessionKey: input.sessionKey,
|
||||
channel,
|
||||
target: to,
|
||||
resolvedFrom: "explicit",
|
||||
accountId: accountId ?? undefined,
|
||||
dryRun,
|
||||
});
|
||||
|
||||
// FIX-3.3: Log dry-run polls
|
||||
if (dryRun) {
|
||||
logDryRunPoll({
|
||||
channel,
|
||||
target: to,
|
||||
question,
|
||||
options,
|
||||
source: pollSource,
|
||||
});
|
||||
}
|
||||
|
||||
const poll = await executePollAction({
|
||||
ctx: {
|
||||
cfg,
|
||||
@ -795,6 +884,11 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
durationHours: durationHours ?? undefined,
|
||||
});
|
||||
|
||||
// FIX-3.1: Record successful poll send for known recipients tracking
|
||||
if (!dryRun && poll.pollResult) {
|
||||
recordRecipient(channel, to);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "poll",
|
||||
channel,
|
||||
@ -851,6 +945,27 @@ export async function runMessageAction(
|
||||
input: RunMessageActionParams,
|
||||
): Promise<MessageActionRunResult> {
|
||||
const cfg = input.cfg;
|
||||
|
||||
// FIX-1.5: Block direct message sends from subagent contexts unless explicitly allowed
|
||||
const sessionKey = input.sessionKey;
|
||||
if (sessionKey && isSubagentSessionKey(sessionKey)) {
|
||||
const allowDirectSend = cfg.agents?.defaults?.subagents?.allowDirectMessageSend ?? false;
|
||||
if (!allowDirectSend) {
|
||||
const action = input.action;
|
||||
// Block send, poll, and broadcast actions from subagents
|
||||
if (action === "send" || action === "poll" || action === "broadcast") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`[security] Subagent ${sessionKey} attempted direct message send - blocked. ` +
|
||||
`Use sessions_send instead, or set agents.defaults.subagents.allowDirectMessageSend: true to allow.`,
|
||||
);
|
||||
throw new Error(
|
||||
`Subagent direct message send blocked. Use sessions_send to route through parent session.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const params = { ...input.params };
|
||||
const resolvedAgentId =
|
||||
input.agentId ??
|
||||
@ -914,7 +1029,9 @@ export async function runMessageAction(
|
||||
if (accountId) {
|
||||
params.accountId = accountId;
|
||||
}
|
||||
const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun"));
|
||||
// FIX-3.3: Check for environment variable dry-run mode
|
||||
const envDryRun = isDryRunModeEnabled();
|
||||
const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun") ?? envDryRun);
|
||||
|
||||
await hydrateSendAttachmentParams({
|
||||
cfg,
|
||||
|
||||
144
src/infra/outbound/send-logging.test.ts
Normal file
144
src/infra/outbound/send-logging.test.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatSendLogLine, inferSendSource, type SendLogContext } from "./send-logging.js";
|
||||
|
||||
describe("send-logging", () => {
|
||||
describe("formatSendLogLine", () => {
|
||||
it("formats basic send context", () => {
|
||||
const ctx: SendLogContext = {
|
||||
source: "cli",
|
||||
channel: "whatsapp",
|
||||
target: "+16505551234",
|
||||
resolvedFrom: "explicit",
|
||||
};
|
||||
const line = formatSendLogLine(ctx);
|
||||
expect(line).toContain("source=cli");
|
||||
expect(line).toContain("channel=whatsapp");
|
||||
expect(line).toContain("target=+165***1234");
|
||||
expect(line).toContain("resolvedFrom=explicit");
|
||||
});
|
||||
|
||||
it("includes session key when provided", () => {
|
||||
const ctx: SendLogContext = {
|
||||
source: "session",
|
||||
sessionKey: "agent:main:telegram:12345",
|
||||
channel: "telegram",
|
||||
target: "12345",
|
||||
resolvedFrom: "session",
|
||||
};
|
||||
const line = formatSendLogLine(ctx);
|
||||
expect(line).toContain("sessionKey=agent:main:telegram:12345");
|
||||
});
|
||||
|
||||
it("includes resolved target when different from target", () => {
|
||||
const ctx: SendLogContext = {
|
||||
source: "rpc",
|
||||
channel: "whatsapp",
|
||||
target: "John",
|
||||
resolvedTarget: "+16505551234",
|
||||
resolvedFrom: "directory",
|
||||
};
|
||||
const line = formatSendLogLine(ctx);
|
||||
expect(line).toContain("resolvedTarget=+165***1234");
|
||||
});
|
||||
|
||||
it("omits resolved target when same as target", () => {
|
||||
const ctx: SendLogContext = {
|
||||
source: "cli",
|
||||
channel: "whatsapp",
|
||||
target: "+16505551234",
|
||||
resolvedTarget: "+16505551234",
|
||||
resolvedFrom: "explicit",
|
||||
};
|
||||
const line = formatSendLogLine(ctx);
|
||||
expect(line).not.toContain("resolvedTarget=");
|
||||
});
|
||||
|
||||
it("includes dry-run flag", () => {
|
||||
const ctx: SendLogContext = {
|
||||
source: "cli",
|
||||
channel: "whatsapp",
|
||||
target: "+16505551234",
|
||||
resolvedFrom: "explicit",
|
||||
dryRun: true,
|
||||
};
|
||||
const line = formatSendLogLine(ctx);
|
||||
expect(line).toContain("dryRun=true");
|
||||
});
|
||||
|
||||
it("includes first-time recipient flag", () => {
|
||||
const ctx: SendLogContext = {
|
||||
source: "rpc",
|
||||
channel: "whatsapp",
|
||||
target: "+16505551234",
|
||||
resolvedFrom: "explicit",
|
||||
firstTimeRecipient: true,
|
||||
};
|
||||
const line = formatSendLogLine(ctx);
|
||||
expect(line).toContain("firstTime=true");
|
||||
});
|
||||
|
||||
it("masks phone numbers correctly", () => {
|
||||
const ctx: SendLogContext = {
|
||||
source: "cli",
|
||||
channel: "whatsapp",
|
||||
target: "+16505551234",
|
||||
resolvedFrom: "explicit",
|
||||
};
|
||||
const line = formatSendLogLine(ctx);
|
||||
expect(line).toContain("+165***1234");
|
||||
expect(line).not.toContain("+16505551234");
|
||||
});
|
||||
|
||||
it("masks WhatsApp JIDs correctly", () => {
|
||||
const ctx: SendLogContext = {
|
||||
source: "rpc",
|
||||
channel: "whatsapp",
|
||||
target: "16505551234@s.whatsapp.net",
|
||||
resolvedFrom: "explicit",
|
||||
};
|
||||
const line = formatSendLogLine(ctx);
|
||||
expect(line).toContain("165***234@s.whatsapp.net");
|
||||
});
|
||||
|
||||
it("does not mask group JIDs", () => {
|
||||
const ctx: SendLogContext = {
|
||||
source: "rpc",
|
||||
channel: "whatsapp",
|
||||
target: "1234567890@g.us",
|
||||
resolvedFrom: "explicit",
|
||||
};
|
||||
const line = formatSendLogLine(ctx);
|
||||
expect(line).toContain("1234567890@g.us");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferSendSource", () => {
|
||||
it("returns sub-agent when isSubagent is true", () => {
|
||||
expect(inferSendSource({ isSubagent: true })).toBe("sub-agent");
|
||||
});
|
||||
|
||||
it("returns tool when isTool is true", () => {
|
||||
expect(inferSendSource({ isTool: true })).toBe("tool");
|
||||
});
|
||||
|
||||
it("returns rpc when isRpc is true", () => {
|
||||
expect(inferSendSource({ isRpc: true })).toBe("rpc");
|
||||
});
|
||||
|
||||
it("returns cli when isCli is true", () => {
|
||||
expect(inferSendSource({ isCli: true })).toBe("cli");
|
||||
});
|
||||
|
||||
it("returns session when sessionKey is provided", () => {
|
||||
expect(inferSendSource({ sessionKey: "agent:main:telegram:123" })).toBe("session");
|
||||
});
|
||||
|
||||
it("returns unknown when nothing is provided", () => {
|
||||
expect(inferSendSource({})).toBe("unknown");
|
||||
});
|
||||
|
||||
it("prioritizes sub-agent over other sources", () => {
|
||||
expect(inferSendSource({ isSubagent: true, isRpc: true, isCli: true })).toBe("sub-agent");
|
||||
});
|
||||
});
|
||||
});
|
||||
108
src/infra/outbound/send-logging.ts
Normal file
108
src/infra/outbound/send-logging.ts
Normal file
@ -0,0 +1,108 @@
|
||||
/**
|
||||
* FIX-2.2: Request logging for outbound sends.
|
||||
* Provides structured logging for all send operations with full request context.
|
||||
*/
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
|
||||
const log = createSubsystemLogger("outbound/send");
|
||||
|
||||
export type SendLogContext = {
|
||||
source: "cli" | "rpc" | "session" | "sub-agent" | "tool" | "unknown";
|
||||
sessionKey?: string;
|
||||
channel: string;
|
||||
target: string;
|
||||
resolvedTarget?: string;
|
||||
resolvedFrom: "explicit" | "session" | "fallback" | "allowlist" | "directory";
|
||||
accountId?: string;
|
||||
callerIdentity?: string;
|
||||
dryRun?: boolean;
|
||||
firstTimeRecipient?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a send log context into a structured log line.
|
||||
*/
|
||||
export function formatSendLogLine(ctx: SendLogContext): string {
|
||||
const parts = [
|
||||
`source=${ctx.source}`,
|
||||
ctx.sessionKey ? `sessionKey=${ctx.sessionKey}` : null,
|
||||
`channel=${ctx.channel}`,
|
||||
`target=${maskTarget(ctx.target)}`,
|
||||
ctx.resolvedTarget && ctx.resolvedTarget !== ctx.target
|
||||
? `resolvedTarget=${maskTarget(ctx.resolvedTarget)}`
|
||||
: null,
|
||||
`resolvedFrom=${ctx.resolvedFrom}`,
|
||||
ctx.accountId ? `accountId=${ctx.accountId}` : null,
|
||||
ctx.callerIdentity ? `caller=${ctx.callerIdentity}` : null,
|
||||
ctx.dryRun ? "dryRun=true" : null,
|
||||
ctx.firstTimeRecipient ? "firstTime=true" : null,
|
||||
].filter(Boolean);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask sensitive parts of a target (phone numbers, emails) for logging.
|
||||
* Keeps enough info to identify the target without full exposure.
|
||||
*/
|
||||
function maskTarget(target: string): string {
|
||||
// For group IDs, show as-is (less sensitive) - check this FIRST
|
||||
if (target.includes("@g.us") || target.startsWith("group:") || target.startsWith("channel:")) {
|
||||
return target;
|
||||
}
|
||||
// For phone numbers like +16503802766, show +1650***2766
|
||||
if (/^\+\d{10,}$/.test(target)) {
|
||||
return target.slice(0, 4) + "***" + target.slice(-4);
|
||||
}
|
||||
// For WhatsApp JIDs like 16503802766@s.whatsapp.net (individual chats)
|
||||
const jidMatch = target.match(/^(\d+)@(.+)$/);
|
||||
if (jidMatch) {
|
||||
const [, num, domain] = jidMatch;
|
||||
if (num.length >= 8) {
|
||||
return num.slice(0, 3) + "***" + num.slice(-3) + "@" + domain;
|
||||
}
|
||||
}
|
||||
// For email-like targets
|
||||
if (target.includes("@") && !target.includes("whatsapp")) {
|
||||
const [local, domain] = target.split("@");
|
||||
if (local.length > 3) {
|
||||
return local.slice(0, 2) + "***@" + domain;
|
||||
}
|
||||
}
|
||||
// Default: mask middle if long enough
|
||||
if (target.length > 8) {
|
||||
return target.slice(0, 3) + "***" + target.slice(-3);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a send request with full context.
|
||||
*/
|
||||
export function logSendRequest(ctx: SendLogContext): void {
|
||||
const line = formatSendLogLine(ctx);
|
||||
if (ctx.firstTimeRecipient) {
|
||||
log.warn(`[send] ${line}`);
|
||||
} else if (ctx.dryRun) {
|
||||
log.info(`[dry-run] ${line}`);
|
||||
} else {
|
||||
log.info(`[send] ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the source of a send request based on context.
|
||||
*/
|
||||
export function inferSendSource(params: {
|
||||
sessionKey?: string;
|
||||
isSubagent?: boolean;
|
||||
isRpc?: boolean;
|
||||
isCli?: boolean;
|
||||
isTool?: boolean;
|
||||
}): SendLogContext["source"] {
|
||||
if (params.isSubagent) return "sub-agent";
|
||||
if (params.isTool) return "tool";
|
||||
if (params.isRpc) return "rpc";
|
||||
if (params.isCli) return "cli";
|
||||
if (params.sessionKey) return "session";
|
||||
return "unknown";
|
||||
}
|
||||
@ -18,11 +18,15 @@ export type TargetResolveKind = ChannelDirectoryEntryKind | "channel";
|
||||
|
||||
export type ResolveAmbiguousMode = "error" | "best" | "first";
|
||||
|
||||
export type TargetResolutionMethod = "explicit" | "directory" | "allowlist" | "fallback";
|
||||
|
||||
export type ResolvedMessagingTarget = {
|
||||
to: string;
|
||||
kind: TargetResolveKind;
|
||||
display?: string;
|
||||
source: "normalized" | "directory";
|
||||
/** How the target was resolved - used for audit logging (FIX-2.2) */
|
||||
from?: TargetResolutionMethod;
|
||||
};
|
||||
|
||||
export type ResolveMessagingTargetResult =
|
||||
@ -324,6 +328,7 @@ export async function resolveMessagingTarget(params: {
|
||||
kind,
|
||||
display: stripTargetPrefixes(raw),
|
||||
source: "normalized",
|
||||
from: "explicit",
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -347,6 +352,7 @@ export async function resolveMessagingTarget(params: {
|
||||
kind,
|
||||
display: entry.name ?? entry.handle ?? stripTargetPrefixes(entry.id),
|
||||
source: "directory",
|
||||
from: "directory",
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -362,6 +368,7 @@ export async function resolveMessagingTarget(params: {
|
||||
kind,
|
||||
display: best.name ?? best.handle ?? stripTargetPrefixes(best.id),
|
||||
source: "directory",
|
||||
from: "directory",
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -386,6 +393,7 @@ export async function resolveMessagingTarget(params: {
|
||||
kind,
|
||||
display: stripTargetPrefixes(raw),
|
||||
source: "normalized",
|
||||
from: "fallback",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -208,6 +208,172 @@ describe("resolveOutboundTarget", () => {
|
||||
expect(res).toEqual({ ok: true, to: "+1555000001" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("whatsapp automation recipients validation (FIX-1.6)", () => {
|
||||
it("blocks heartbeat mode send when target not in automation.recipients", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+1555000001", "+1555999999"],
|
||||
automation: { recipients: ["+1555000001"] },
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = resolveOutboundTarget({
|
||||
channel: "whatsapp",
|
||||
to: "+1555999999",
|
||||
cfg,
|
||||
mode: "heartbeat",
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.error.message).toContain("automation.recipients");
|
||||
expect(res.error.message).toContain("Automation send");
|
||||
}
|
||||
});
|
||||
|
||||
it("allows heartbeat mode send when target is in automation.recipients", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+1555000001", "+1555999999"],
|
||||
automation: { recipients: ["+1555000001"] },
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = resolveOutboundTarget({
|
||||
channel: "whatsapp",
|
||||
to: "+1555000001",
|
||||
cfg,
|
||||
mode: "heartbeat",
|
||||
});
|
||||
expect(res).toEqual({ ok: true, to: "+1555000001" });
|
||||
});
|
||||
|
||||
it("blocks automation mode send when target not in automation.recipients", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+1555000001", "+1555999999"],
|
||||
automation: { recipients: ["+1555000001"] },
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = resolveOutboundTarget({
|
||||
channel: "whatsapp",
|
||||
to: "+1555999999",
|
||||
cfg,
|
||||
mode: "automation",
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.error.message).toContain("automation.recipients");
|
||||
}
|
||||
});
|
||||
|
||||
it("allows automation mode send when target is in automation.recipients", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+1555000001"],
|
||||
automation: { recipients: ["+1555000001"] },
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = resolveOutboundTarget({
|
||||
channel: "whatsapp",
|
||||
to: "+1555000001",
|
||||
cfg,
|
||||
mode: "automation",
|
||||
});
|
||||
expect(res).toEqual({ ok: true, to: "+1555000001" });
|
||||
});
|
||||
|
||||
it("blocks automation when automation.recipients is explicitly empty", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+1555000001", "+1555000002"],
|
||||
automation: { recipients: [] },
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = resolveOutboundTarget({
|
||||
channel: "whatsapp",
|
||||
to: "+1555999999",
|
||||
cfg,
|
||||
mode: "heartbeat",
|
||||
});
|
||||
// FIX-2: When automation.recipients is explicitly empty, block ALL automation sends
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.error.message).toContain("automation.recipients is configured but empty");
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to allowlist when automation.recipients is NOT configured (undefined)", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+1555000001", "+1555000002"],
|
||||
// automation.recipients is undefined - not configured
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = resolveOutboundTarget({
|
||||
channel: "whatsapp",
|
||||
to: "+1555999999",
|
||||
cfg,
|
||||
mode: "heartbeat",
|
||||
});
|
||||
// When automation.recipients is NOT configured (undefined), fall back to allowlist
|
||||
expect(res).toEqual({ ok: true, to: "+1555000001" });
|
||||
});
|
||||
|
||||
it("allows groups in heartbeat mode regardless of automation.recipients", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+1555000001"],
|
||||
automation: { recipients: ["+1555000001"] },
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = resolveOutboundTarget({
|
||||
channel: "whatsapp",
|
||||
to: "120363401234567890@g.us",
|
||||
cfg,
|
||||
mode: "heartbeat",
|
||||
});
|
||||
expect(res).toEqual({ ok: true, to: "120363401234567890@g.us" });
|
||||
});
|
||||
|
||||
it("uses account-level automation.recipients when configured", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+1555000001"],
|
||||
automation: { recipients: ["+1555000001"] },
|
||||
accounts: {
|
||||
personal: {
|
||||
allowFrom: ["+1555000001", "+1555000002"],
|
||||
automation: { recipients: ["+1555000002"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
// Account-level automation.recipients (+1555000002) should take precedence over global (+1555000001)
|
||||
const res = resolveOutboundTarget({
|
||||
channel: "whatsapp",
|
||||
to: "+1555000002",
|
||||
cfg,
|
||||
accountId: "personal",
|
||||
mode: "heartbeat",
|
||||
});
|
||||
expect(res).toEqual({ ok: true, to: "+1555000002" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHeartbeatDeliveryTarget", () => {
|
||||
@ -220,7 +386,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns none with reason require-explicit when requireExplicitTarget is true and no to is set", () => {
|
||||
it("throws error when requireExplicitTarget is true and no to is set", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
@ -233,12 +399,35 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["+1555000001"] } },
|
||||
};
|
||||
const result = resolveHeartbeatDeliveryTarget({
|
||||
cfg,
|
||||
entry: { sessionId: "test", updatedAt: 1, lastChannel: "whatsapp", lastTo: "+1555000001" },
|
||||
});
|
||||
expect(result.channel).toBe("none");
|
||||
expect(result.reason).toBe("require-explicit");
|
||||
// FIX-1.4: Now throws instead of returning channel: "none" (stricter behavior)
|
||||
expect(() =>
|
||||
resolveHeartbeatDeliveryTarget({
|
||||
cfg,
|
||||
entry: { sessionId: "test", updatedAt: 1, lastChannel: "whatsapp", lastTo: "+1555000001" },
|
||||
}),
|
||||
).toThrow(/requireExplicitTarget is enabled but no explicit 'to' target was provided/);
|
||||
});
|
||||
|
||||
it("throws error when requireExplicitTarget defaults to true and no to is set", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
target: "whatsapp",
|
||||
// requireExplicitTarget defaults to true now
|
||||
// no `to` set
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["+1555000001"] } },
|
||||
};
|
||||
// FIX-1.4: Default is now strict mode - throws if no explicit target
|
||||
expect(() =>
|
||||
resolveHeartbeatDeliveryTarget({
|
||||
cfg,
|
||||
entry: { sessionId: "test", updatedAt: 1, lastChannel: "whatsapp", lastTo: "+1555000001" },
|
||||
}),
|
||||
).toThrow(/requireExplicitTarget is enabled but no explicit 'to' target was provided/);
|
||||
});
|
||||
|
||||
it("allows heartbeat delivery when requireExplicitTarget is true and to is set", () => {
|
||||
@ -262,13 +451,14 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
||||
expect(result.to).toBe("+1555000001");
|
||||
});
|
||||
|
||||
it("allows implicit routing when requireExplicitTarget is false (default)", () => {
|
||||
it("allows implicit routing when requireExplicitTarget is explicitly set to false", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
target: "last",
|
||||
// requireExplicitTarget defaults to false
|
||||
// Must explicitly set to false to allow implicit routing
|
||||
requireExplicitTarget: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -182,7 +182,8 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
||||
const { cfg, entry } = params;
|
||||
const heartbeat = params.heartbeat ?? cfg.agents?.defaults?.heartbeat;
|
||||
const rawTarget = heartbeat?.target;
|
||||
const requireExplicitTarget = heartbeat?.requireExplicitTarget ?? false;
|
||||
// FIX-1.4: Default to strict mode - require explicit target for heartbeat delivery
|
||||
const requireExplicitTarget = heartbeat?.requireExplicitTarget ?? true;
|
||||
const explicitTo = heartbeat?.to?.trim();
|
||||
let target: HeartbeatTarget = "last";
|
||||
if (rawTarget === "none" || rawTarget === "last") {
|
||||
@ -203,16 +204,13 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
||||
};
|
||||
}
|
||||
|
||||
// When requireExplicitTarget is enabled, only allow delivery if an explicit target is set
|
||||
// FIX-1.4: When requireExplicitTarget is enabled, throw an error if no explicit target is set
|
||||
// This prevents accidental sends to stale/implicit targets from cron/system events
|
||||
if (requireExplicitTarget && !explicitTo) {
|
||||
const base = resolveSessionDeliveryTarget({ entry });
|
||||
return {
|
||||
channel: "none",
|
||||
reason: "require-explicit",
|
||||
accountId: undefined,
|
||||
lastChannel: base.lastChannel,
|
||||
lastAccountId: base.lastAccountId,
|
||||
};
|
||||
throw new Error(
|
||||
`Heartbeat delivery blocked: requireExplicitTarget is enabled but no explicit 'to' target was provided. ` +
|
||||
`Set agents.defaults.heartbeat.to to an explicit target, or set agents.defaults.heartbeat.requireExplicitTarget: false to allow implicit routing.`,
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedTarget = resolveSessionDeliveryTarget({
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSenderTrustedForDeliveryContext } from "./delivery-context.js";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
isSenderTrustedForDeliveryContext,
|
||||
isDeliveryContextExpired,
|
||||
createDeliveryContext,
|
||||
normalizeDeliveryContext,
|
||||
DEFAULT_DELIVERY_CONTEXT_TTL_MS,
|
||||
} from "./delivery-context.js";
|
||||
|
||||
describe("isSenderTrustedForDeliveryContext", () => {
|
||||
it("returns true when trustAll is set", () => {
|
||||
@ -90,3 +96,100 @@ describe("isSenderTrustedForDeliveryContext", () => {
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDeliveryContextExpired", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns true for undefined context", () => {
|
||||
expect(isDeliveryContextExpired(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for context without updatedAt", () => {
|
||||
expect(isDeliveryContextExpired({ channel: "whatsapp", to: "+1555000001" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for fresh context within TTL", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
const context = {
|
||||
channel: "whatsapp",
|
||||
to: "+1555000001",
|
||||
updatedAt: now - 1000, // 1 second ago
|
||||
};
|
||||
expect(isDeliveryContextExpired(context)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for context older than default TTL (24 hours)", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
const context = {
|
||||
channel: "whatsapp",
|
||||
to: "+1555000001",
|
||||
updatedAt: now - DEFAULT_DELIVERY_CONTEXT_TTL_MS - 1000, // Just over 24 hours ago
|
||||
};
|
||||
expect(isDeliveryContextExpired(context)).toBe(true);
|
||||
});
|
||||
|
||||
it("respects custom TTL parameter", () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
const oneHourMs = 60 * 60 * 1000;
|
||||
const context = {
|
||||
channel: "whatsapp",
|
||||
to: "+1555000001",
|
||||
updatedAt: now - oneHourMs - 1000, // Just over 1 hour ago
|
||||
};
|
||||
// With default TTL (24h), this should not be expired
|
||||
expect(isDeliveryContextExpired(context)).toBe(false);
|
||||
// With 1 hour TTL, this should be expired
|
||||
expect(isDeliveryContextExpired(context, oneHourMs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDeliveryContext", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("creates context with current timestamp", () => {
|
||||
const now = 1700000000000;
|
||||
vi.setSystemTime(now);
|
||||
const context = createDeliveryContext({
|
||||
channel: "whatsapp",
|
||||
to: "+1555000001",
|
||||
});
|
||||
expect(context.channel).toBe("whatsapp");
|
||||
expect(context.to).toBe("+1555000001");
|
||||
expect(context.updatedAt).toBe(now);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeDeliveryContext", () => {
|
||||
it("preserves updatedAt timestamp", () => {
|
||||
const context = normalizeDeliveryContext({
|
||||
channel: "whatsapp",
|
||||
to: "+1555000001",
|
||||
updatedAt: 1700000000000,
|
||||
});
|
||||
expect(context?.updatedAt).toBe(1700000000000);
|
||||
});
|
||||
|
||||
it("handles invalid updatedAt values", () => {
|
||||
const context = normalizeDeliveryContext({
|
||||
channel: "whatsapp",
|
||||
to: "+1555000001",
|
||||
updatedAt: NaN,
|
||||
});
|
||||
expect(context?.updatedAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,11 +1,16 @@
|
||||
import { normalizeAccountId } from "./account-id.js";
|
||||
import { normalizeMessageChannel } from "./message-channel.js";
|
||||
|
||||
/** Default TTL for delivery context in milliseconds (24 hours) */
|
||||
export const DEFAULT_DELIVERY_CONTEXT_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type DeliveryContext = {
|
||||
channel?: string;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
threadId?: string | number;
|
||||
/** Timestamp when this context was last updated (ms since epoch) */
|
||||
updatedAt?: number;
|
||||
};
|
||||
|
||||
export type DeliveryContextTrustParams = {
|
||||
@ -55,6 +60,33 @@ export type DeliveryContextSessionSource = {
|
||||
deliveryContext?: DeliveryContext;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a delivery context has expired based on its updatedAt timestamp.
|
||||
* @param context The delivery context to check
|
||||
* @param ttlMs TTL in milliseconds (default: 24 hours)
|
||||
* @returns true if the context has expired or has no timestamp
|
||||
*/
|
||||
export function isDeliveryContextExpired(
|
||||
context?: DeliveryContext,
|
||||
ttlMs: number = DEFAULT_DELIVERY_CONTEXT_TTL_MS,
|
||||
): boolean {
|
||||
if (!context) return true;
|
||||
const updatedAt = context.updatedAt;
|
||||
if (typeof updatedAt !== "number" || !Number.isFinite(updatedAt)) return true;
|
||||
const now = Date.now();
|
||||
return now - updatedAt > ttlMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh delivery context with the current timestamp.
|
||||
*/
|
||||
export function createDeliveryContext(params: Omit<DeliveryContext, "updatedAt">): DeliveryContext {
|
||||
return {
|
||||
...normalizeDeliveryContext(params),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDeliveryContext(context?: DeliveryContext): DeliveryContext | undefined {
|
||||
if (!context) return undefined;
|
||||
const channel =
|
||||
@ -71,6 +103,11 @@ export function normalizeDeliveryContext(context?: DeliveryContext): DeliveryCon
|
||||
: undefined;
|
||||
const normalizedThreadId =
|
||||
typeof threadId === "string" ? (threadId ? threadId : undefined) : threadId;
|
||||
// Preserve updatedAt timestamp if present
|
||||
const updatedAt =
|
||||
typeof context.updatedAt === "number" && Number.isFinite(context.updatedAt)
|
||||
? context.updatedAt
|
||||
: undefined;
|
||||
if (!channel && !to && !accountId && normalizedThreadId == null) return undefined;
|
||||
const normalized: DeliveryContext = {
|
||||
channel: channel || undefined,
|
||||
@ -78,6 +115,7 @@ export function normalizeDeliveryContext(context?: DeliveryContext): DeliveryCon
|
||||
accountId,
|
||||
};
|
||||
if (normalizedThreadId != null) normalized.threadId = normalizedThreadId;
|
||||
if (updatedAt != null) normalized.updatedAt = updatedAt;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@ -129,9 +167,15 @@ export function normalizeSessionDeliveryFields(source?: DeliveryContextSessionSo
|
||||
|
||||
export function deliveryContextFromSession(
|
||||
entry?: DeliveryContextSessionSource,
|
||||
options?: { ttlMs?: number; checkExpiry?: boolean },
|
||||
): DeliveryContext | undefined {
|
||||
if (!entry) return undefined;
|
||||
return normalizeSessionDeliveryFields(entry).deliveryContext;
|
||||
const context = normalizeSessionDeliveryFields(entry).deliveryContext;
|
||||
// FIX-3.2: Check for TTL expiration if requested
|
||||
if (options?.checkExpiry !== false && isDeliveryContextExpired(context, options?.ttlMs)) {
|
||||
return undefined;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function mergeDeliveryContext(
|
||||
@ -141,14 +185,27 @@ export function mergeDeliveryContext(
|
||||
const normalizedPrimary = normalizeDeliveryContext(primary);
|
||||
const normalizedFallback = normalizeDeliveryContext(fallback);
|
||||
if (!normalizedPrimary && !normalizedFallback) return undefined;
|
||||
// Use the most recent timestamp from either context
|
||||
const primaryUpdatedAt = normalizedPrimary?.updatedAt ?? 0;
|
||||
const fallbackUpdatedAt = normalizedFallback?.updatedAt ?? 0;
|
||||
const updatedAt = Math.max(primaryUpdatedAt, fallbackUpdatedAt) || Date.now();
|
||||
return normalizeDeliveryContext({
|
||||
channel: normalizedPrimary?.channel ?? normalizedFallback?.channel,
|
||||
to: normalizedPrimary?.to ?? normalizedFallback?.to,
|
||||
accountId: normalizedPrimary?.accountId ?? normalizedFallback?.accountId,
|
||||
threadId: normalizedPrimary?.threadId ?? normalizedFallback?.threadId,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the delivery context (returns undefined).
|
||||
* Used when resetting routing state (e.g., on logout).
|
||||
*/
|
||||
export function clearDeliveryContext(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function deliveryContextKey(context?: DeliveryContext): string | undefined {
|
||||
const normalized = normalizeDeliveryContext(context);
|
||||
if (!normalized?.channel || !normalized?.to) return undefined;
|
||||
|
||||
@ -28,6 +28,7 @@ export type ResolvedWhatsAppAccount = {
|
||||
ackReaction?: WhatsAppAccountConfig["ackReaction"];
|
||||
groups?: WhatsAppAccountConfig["groups"];
|
||||
debounceMs?: number;
|
||||
automation?: WhatsAppAccountConfig["automation"];
|
||||
};
|
||||
|
||||
function listConfiguredAccountIds(cfg: MoltbotConfig): string[] {
|
||||
@ -157,6 +158,7 @@ export function resolveWhatsAppAccount(params: {
|
||||
ackReaction: accountCfg?.ackReaction ?? rootCfg?.ackReaction,
|
||||
groups: accountCfg?.groups ?? rootCfg?.groups,
|
||||
debounceMs: accountCfg?.debounceMs ?? rootCfg?.debounceMs,
|
||||
automation: accountCfg?.automation ?? rootCfg?.automation,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user