feat: same-phone mode with echo detection and configurable marker

Adds full support for self-messaging setups where you chat with yourself
and an AI assistant replies in the same WhatsApp bubble.

Changes:
- Same-phone mode (from === to) always allowed, bypasses allowFrom check
- Echo detection via bounded Set (max 100) prevents infinite loops
- Configurable samePhoneMarker in config (default: "[same-phone]")
- Messages prefixed with marker so assistants know the context
- fromMe filter removed from inbound.ts (echo detection in auto-reply)
- Verbose logging for same-phone detection and echo skips

Tests:
- Same-phone allowed without/despite allowFrom configuration
- Body prefixed only when from === to
- Non-same-phone rejected when not in allowFrom
This commit is contained in:
Peter Steinberger
2025-11-29 04:50:56 +00:00
parent 5bafe9483d
commit d88ede92b9
9 changed files with 202 additions and 4 deletions

View File

@@ -945,4 +945,73 @@ describe("web auto-reply", () => {
expect(content).toContain('"module":"web-auto-reply"');
expect(content).toContain('"text":"auto"');
});
it("prefixes body with same-phone marker when from === to", async () => {
let capturedOnMessage:
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (
msg: import("./inbound.js").WebInboundMessage,
) => Promise<void>;
}) => {
capturedOnMessage = opts.onMessage;
return { close: vi.fn() };
};
const resolver = vi.fn().mockResolvedValue({ text: "reply" });
await monitorWebProvider(false, listenerFactory, false, resolver);
expect(capturedOnMessage).toBeDefined();
await capturedOnMessage?.({
body: "hello",
from: "+1555",
to: "+1555", // Same phone!
id: "msg1",
sendComposing: vi.fn(),
reply: vi.fn(),
sendMedia: vi.fn(),
});
// The resolver should receive a prefixed body (the exact marker depends on config)
// Key test: body should start with some marker and end with original message
const callArg = resolver.mock.calls[0]?.[0] as { Body?: string };
expect(callArg?.Body).toBeDefined();
expect(callArg?.Body).toMatch(/^\[.*\] hello$/);
expect(callArg?.Body).not.toBe("hello"); // Should be prefixed
});
it("does not prefix body when from !== to", async () => {
let capturedOnMessage:
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (
msg: import("./inbound.js").WebInboundMessage,
) => Promise<void>;
}) => {
capturedOnMessage = opts.onMessage;
return { close: vi.fn() };
};
const resolver = vi.fn().mockResolvedValue({ text: "reply" });
await monitorWebProvider(false, listenerFactory, false, resolver);
expect(capturedOnMessage).toBeDefined();
await capturedOnMessage?.({
body: "hello",
from: "+1555",
to: "+2666", // Different phones
id: "msg1",
sendComposing: vi.fn(),
reply: vi.fn(),
sendMedia: vi.fn(),
});
// Body should NOT be prefixed
const callArg = resolver.mock.calls[0]?.[0] as { Body?: string };
expect(callArg?.Body).toBe("hello");
});
});

View File

@@ -501,6 +501,10 @@ export async function monitorWebProvider(
let reconnectAttempts = 0;
// Track recently sent messages to prevent echo loops
const recentlySent = new Set<string>();
const MAX_RECENT_MESSAGES = 100;
while (true) {
if (stopRequested()) break;
@@ -536,11 +540,38 @@ export async function monitorWebProvider(
console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`);
// Detect same-phone mode (self-messaging)
const isSamePhoneMode = msg.from === msg.to;
if (isSamePhoneMode) {
logVerbose(`📱 Same-phone mode detected (from === to: ${msg.from})`);
}
// Skip if this is a message we just sent (echo detection)
if (recentlySent.has(msg.body)) {
console.log(`⏭️ Skipping echo: detected recently sent message`);
logVerbose(
`Skipping auto-reply: detected echo (message matches recently sent text)`,
);
recentlySent.delete(msg.body); // Remove from set to allow future identical messages
return;
}
logVerbose(
`Echo check: message not in recent set (size: ${recentlySent.size})`,
);
lastInboundMsg = msg;
// Prefix body with marker in same-phone mode so the assistant knows to prefix replies
// The marker can be customized via config (default: "[same-phone]")
const samePhoneMarker = cfg.inbound?.samePhoneMarker ?? "[same-phone]";
const bodyForCommand = isSamePhoneMode
? `${samePhoneMarker} ${msg.body}`
: msg.body;
const replyResult = await (replyResolver ?? getReplyFromConfig)(
{
Body: msg.body,
Body: bodyForCommand,
From: msg.from,
To: msg.to,
MessageSid: msg.id,
@@ -572,6 +603,20 @@ export async function monitorWebProvider(
runtime,
connectionId,
});
// Track sent message to prevent echo loops
if (replyResult.text) {
recentlySent.add(replyResult.text);
logVerbose(
`Added to echo detection set (size now: ${recentlySent.size}): ${replyResult.text.substring(0, 50)}...`,
);
// Keep set bounded - remove oldest if too large
if (recentlySent.size > MAX_RECENT_MESSAGES) {
const firstKey = recentlySent.values().next().value;
if (firstKey) recentlySent.delete(firstKey);
}
}
if (isVerbose()) {
console.log(
success(

View File

@@ -70,7 +70,7 @@ export async function monitorWebInbox(options: {
// De-dupe on message id; Baileys can emit retries.
if (id && seen.has(id)) continue;
if (id) seen.add(id);
if (msg.key?.fromMe) continue;
// Note: not filtering fromMe here - echo detection happens in auto-reply layer
const remoteJid = msg.key?.remoteJid;
if (!remoteJid) continue;
// Ignore status/broadcast traffic; we only care about direct chats.