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 allowFrommain
parent
5bafe9483d
commit
d88ede92b9
|
|
@ -1,5 +1,13 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.2.3 — Unreleased
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- **Same-phone mode (self-messaging):** warelay now supports running on the same phone number you message from. This enables setups where you chat with yourself to control an AI assistant. Same-phone mode (`from === to`) is always allowed, even without configuring `allowFrom`. Echo detection prevents infinite loops by tracking recently sent message text and skipping auto-replies when incoming messages match.
|
||||||
|
- **Echo detection:** The `fromMe` filter in `inbound.ts` is deliberately removed for same-phone setups; instead, text-based echo detection in `auto-reply.ts` tracks sent messages in a bounded Set (max 100 entries) and skips processing when a match is found.
|
||||||
|
- **Same-phone detection logging:** Verbose mode now logs `📱 Same-phone mode detected` when `from === to`.
|
||||||
|
- **Configurable same-phone marker:** New `inbound.samePhoneMarker` config option to customize the prefix added to messages in same-phone mode (default: `[same-phone]`). Set it to something cute like `[🦞 same-phone]` to help distinguish bot replies.
|
||||||
|
|
||||||
## 1.2.2 — 2025-11-28
|
## 1.2.2 — 2025-11-28
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -93,6 +93,16 @@ Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **on
|
||||||
|
|
||||||
Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business account) for automation instead of your primary personal account to avoid unexpected logouts or rate limits.
|
Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business account) for automation instead of your primary personal account to avoid unexpected logouts or rate limits.
|
||||||
|
|
||||||
|
### Same-phone mode (self-messaging)
|
||||||
|
warelay supports running on the same phone number you message from—you chat with yourself and an AI assistant replies in the same bubble. This requires:
|
||||||
|
- Adding your own number to `allowFrom` in `warelay.json`
|
||||||
|
- The `fromMe` filter is disabled; echo detection in `auto-reply.ts` prevents loops
|
||||||
|
|
||||||
|
**Gotchas:**
|
||||||
|
- Messages appear in the same chat bubble (WhatsApp "Note to self")
|
||||||
|
- Echo detection relies on exact text matching; if the reply is identical to your input, it may be skipped
|
||||||
|
- Works best with a dedicated WhatsApp account
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Environment (.env)
|
### Environment (.env)
|
||||||
|
|
|
||||||
|
|
@ -146,8 +146,14 @@ export async function getReplyFromConfig(
|
||||||
|
|
||||||
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
||||||
const allowFrom = cfg.inbound?.allowFrom;
|
const allowFrom = cfg.inbound?.allowFrom;
|
||||||
if (Array.isArray(allowFrom) && allowFrom.length > 0) {
|
|
||||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
||||||
|
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
||||||
|
const isSamePhone = from && to && from === to;
|
||||||
|
|
||||||
|
// Same-phone mode (self-messaging) is always allowed
|
||||||
|
if (isSamePhone) {
|
||||||
|
logVerbose(`Allowing same-phone mode: from === to (${from})`);
|
||||||
|
} else if (Array.isArray(allowFrom) && allowFrom.length > 0) {
|
||||||
// Support "*" as wildcard to allow all senders
|
// Support "*" as wildcard to allow all senders
|
||||||
if (!allowFrom.includes("*") && !allowFrom.includes(from)) {
|
if (!allowFrom.includes("*") && !allowFrom.includes(from)) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ export type WarelayConfig = {
|
||||||
logging?: LoggingConfig;
|
logging?: LoggingConfig;
|
||||||
inbound?: {
|
inbound?: {
|
||||||
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
|
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
|
||||||
|
samePhoneMarker?: string; // Prefix for same-phone mode messages (default: "[same-phone]")
|
||||||
transcribeAudio?: {
|
transcribeAudio?: {
|
||||||
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
|
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
|
||||||
command: string[];
|
command: string[];
|
||||||
|
|
@ -139,6 +140,7 @@ const WarelaySchema = z.object({
|
||||||
inbound: z
|
inbound: z
|
||||||
.object({
|
.object({
|
||||||
allowFrom: z.array(z.string()).optional(),
|
allowFrom: z.array(z.string()).optional(),
|
||||||
|
samePhoneMarker: z.string().optional(),
|
||||||
transcribeAudio: z
|
transcribeAudio: z
|
||||||
.object({
|
.object({
|
||||||
command: z.array(z.string()),
|
command: z.array(z.string()),
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,64 @@ describe("config and templating", () => {
|
||||||
expect(onReplyStart).toHaveBeenCalled();
|
expect(onReplyStart).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("getReplyFromConfig allows same-phone mode (from === to) without allowFrom", async () => {
|
||||||
|
const cfg = {
|
||||||
|
inbound: {
|
||||||
|
// No allowFrom configured
|
||||||
|
reply: {
|
||||||
|
mode: "text" as const,
|
||||||
|
text: "Echo: {{Body}}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await index.getReplyFromConfig(
|
||||||
|
{ Body: "hello", From: "+1555", To: "+1555" },
|
||||||
|
undefined,
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
expect(result?.text).toBe("Echo: hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getReplyFromConfig allows same-phone mode even when not in allowFrom list", async () => {
|
||||||
|
const cfg = {
|
||||||
|
inbound: {
|
||||||
|
allowFrom: ["+9999"], // Different number
|
||||||
|
reply: {
|
||||||
|
mode: "text" as const,
|
||||||
|
text: "Reply: {{Body}}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Same-phone mode should bypass allowFrom check
|
||||||
|
const result = await index.getReplyFromConfig(
|
||||||
|
{ Body: "test", From: "+1555", To: "+1555" },
|
||||||
|
undefined,
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
expect(result?.text).toBe("Reply: test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getReplyFromConfig rejects non-same-phone when not in allowFrom", async () => {
|
||||||
|
const cfg = {
|
||||||
|
inbound: {
|
||||||
|
allowFrom: ["+9999"],
|
||||||
|
reply: {
|
||||||
|
mode: "text" as const,
|
||||||
|
text: "Should not see this",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await index.getReplyFromConfig(
|
||||||
|
{ Body: "test", From: "+1555", To: "+2666" },
|
||||||
|
undefined,
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("getReplyFromConfig templating includes media fields", async () => {
|
it("getReplyFromConfig templating includes media fields", async () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
inbound: {
|
inbound: {
|
||||||
|
|
|
||||||
|
|
@ -945,4 +945,73 @@ describe("web auto-reply", () => {
|
||||||
expect(content).toContain('"module":"web-auto-reply"');
|
expect(content).toContain('"module":"web-auto-reply"');
|
||||||
expect(content).toContain('"text":"auto"');
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -501,6 +501,10 @@ export async function monitorWebProvider(
|
||||||
|
|
||||||
let reconnectAttempts = 0;
|
let reconnectAttempts = 0;
|
||||||
|
|
||||||
|
// Track recently sent messages to prevent echo loops
|
||||||
|
const recentlySent = new Set<string>();
|
||||||
|
const MAX_RECENT_MESSAGES = 100;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
if (stopRequested()) break;
|
if (stopRequested()) break;
|
||||||
|
|
||||||
|
|
@ -536,11 +540,38 @@ export async function monitorWebProvider(
|
||||||
|
|
||||||
console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`);
|
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;
|
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)(
|
const replyResult = await (replyResolver ?? getReplyFromConfig)(
|
||||||
{
|
{
|
||||||
Body: msg.body,
|
Body: bodyForCommand,
|
||||||
From: msg.from,
|
From: msg.from,
|
||||||
To: msg.to,
|
To: msg.to,
|
||||||
MessageSid: msg.id,
|
MessageSid: msg.id,
|
||||||
|
|
@ -572,6 +603,20 @@ export async function monitorWebProvider(
|
||||||
runtime,
|
runtime,
|
||||||
connectionId,
|
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()) {
|
if (isVerbose()) {
|
||||||
console.log(
|
console.log(
|
||||||
success(
|
success(
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ export async function monitorWebInbox(options: {
|
||||||
// De-dupe on message id; Baileys can emit retries.
|
// De-dupe on message id; Baileys can emit retries.
|
||||||
if (id && seen.has(id)) continue;
|
if (id && seen.has(id)) continue;
|
||||||
if (id) seen.add(id);
|
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;
|
const remoteJid = msg.key?.remoteJid;
|
||||||
if (!remoteJid) continue;
|
if (!remoteJid) continue;
|
||||||
// Ignore status/broadcast traffic; we only care about direct chats.
|
// Ignore status/broadcast traffic; we only care about direct chats.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue