From d78e5844b48e7cbc560431c4bb01ef8d3853e1b2 Mon Sep 17 00:00:00 2001 From: Shekhar Mudarapu Date: Sat, 19 Sep 2026 09:57:38 -0400 Subject: [PATCH] =?UTF-8?q?fix(imessage):=20the=20unknown-inbound=20ingest?= =?UTF-8?q?er=20must=20not=20swallow=20GROUP=20messages=20=E2=80=94=20pari?= =?UTF-8?q?ty=20with=20the=20WhatsApp=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the introspection loop on its first successful audit in ~4 weeks (it wrote the fix + regression test before its run died on a network error, leaving them uncommitted). `maybeIngestUnknownInbound` was called without the `!isGroup` guard the WhatsApp bridge already has at its call site, so an appointment-shaped message in a GROUP could be claimed by the life-event/appointment ingester and the contact reply suppressed — a silent drop in a group thread. `isGroup` is already a parameter of the enclosing `handleInbound`; every sibling branch around it is `!isGroup`-gated. Verified: imessage-bridge 118/0 (incl. the new unknown-inbound-group-scope regression test), tsc clean. --- services/imessage-bridge/src/session.ts | 7 ++++- .../src/unknown-inbound-group-scope.test.ts | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 services/imessage-bridge/src/unknown-inbound-group-scope.test.ts diff --git a/services/imessage-bridge/src/session.ts b/services/imessage-bridge/src/session.ts index 5ec00c74..c0219aaf 100644 --- a/services/imessage-bridge/src/session.ts +++ b/services/imessage-bridge/src/session.ts @@ -7373,7 +7373,12 @@ export class IMessageSession { // PROACTIVE INGESTER (unknown senders): appointment confirmations → // surface to the owner + offer to add to the calendar; marketing/spam → // suppress (no auto-reply). Behind LANTERN_APPT_INGEST (default on). - const ingest = await this.maybeIngestUnknownInbound(row.handle, text).catch(() => "pass" as const); + // DM-only, matching every sibling proactive-scan block above and the + // WhatsApp equivalent (isGroupJid guard) — a group participant's message + // is never an "unknown sender" appointment confirmation to swallow. + const ingest = !isGroup + ? await this.maybeIngestUnknownInbound(row.handle, text).catch(() => "pass" as const) + : ("pass" as const); if (ingest === "handled") { // No silent drops: an unknown-inbound message owned by the life-event / // appointment ingester means the CONTACT got no auto-reply. Leave a trace diff --git a/services/imessage-bridge/src/unknown-inbound-group-scope.test.ts b/services/imessage-bridge/src/unknown-inbound-group-scope.test.ts new file mode 100644 index 00000000..a3e01c09 --- /dev/null +++ b/services/imessage-bridge/src/unknown-inbound-group-scope.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; + +// Regression: PROACTIVE INGESTER (maybeIngestUnknownInbound) is designed for +// unknown 1:1 senders ("appointment confirmations from an unrecognized +// number"), but its iMessage call site had no !isGroup guard — unlike every +// sibling proactive-scan block in this file (style-learning, inner-circle +// agentic, proactive-memory) and unlike the WhatsApp bridge's own +// maybeIngestUnknownInbound (`!this.isGroupJid(from)`). A group participant's +// message pattern-matching delivery/appointment language got silently +// swallowed — "contact reply suppressed — unknown-inbound owned by +// life-event/appointment ingestion" — inside live GROUP threads, confirmed +// live 2026-08-18 (chatRowid 2656, isGroup:true) and reconfirmed repeatedly. +// This only fixes the group-scope leak; whether a genuine 1:1 unknown-sender +// appointment text should ever get a contact-facing reply is a separate, +// still-open product question (introspect state.json). + +describe("maybeIngestUnknownInbound call site is DM-only", () => { + const src = readFileSync(new URL("./session.ts", import.meta.url), "utf8"); + + it("the call site checks !isGroup before invoking the ingester", () => { + const anchor = "const ingest = "; + const site = src.slice(src.indexOf(anchor), src.indexOf(anchor) + 300); + expect(site).toMatch(/!isGroup/); + expect(site).toMatch(/maybeIngestUnknownInbound/); + }); +});