From 339e42798478df23884953a795acca8ea93a8c6d Mon Sep 17 00:00:00 2001 From: 0thernet Date: Thu, 1 Oct 2026 02:43:25 -0400 Subject: [PATCH 1/2] Fix legacy WhatsApp interaction projection --- CHANGELOG.md | 8 ++ README.md | 10 +- dist/apple-photos-client.js | 2 +- dist/beeper-client.js | 2 +- dist/{index-4wh7rchw.js => index-w01w5jbv.js} | 2 +- docs/contracts.md | 4 +- docs/publishing.md | 4 +- kb/launch/social-kit.md | 14 +- package.json | 2 +- scripts/npm-release-workflow.test.ts | 18 +-- scripts/package-budget.ts | 22 +-- skills/ghostget/references/install.md | 10 +- src/beeper-client-types.ts | 2 +- src/cli.ts | 2 +- src/contracts-check.test.ts | 2 +- src/contracts.test-support.ts | 2 +- src/media/manifest.test.ts | 2 +- ...sapp-interaction-projection-helper.test.ts | 135 ++++++++++++++++++ .../whatsapp-interaction-projection-helper.ts | 62 +++++++- src/version.ts | 2 +- website/build.ts | 2 +- 21 files changed, 251 insertions(+), 58 deletions(-) rename dist/{index-4wh7rchw.js => index-w01w5jbv.js} (62%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f569270..db217602 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ Historical entries retain their original delivery coordinates. ## Unreleased +## 0.18.66 + +Accept the short legacy system JIDs and inconsistent participant fields emitted by supported wacli migrations when projecting body-free WhatsApp interaction counts. + +- Map bounded short system JIDs to the existing unsupported sentinel. +- Drop participant fields that contradict the stored message direction instead of failing the complete page. +- Keep the strict schema, owner, cursor, and no-message-body guards intact. + ## 0.18.65 Make GhostGet's articles and comparisons calmer, clearer, and more consistent. diff --git a/README.md b/README.md index 289a2bb6..1ae03e0b 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Install [Bun 1.3.14](https://bun.sh/docs/installation) if needed, then install GhostGet and read a public page: ```sh -bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.65/hraness-ghostget-0.18.65.tgz +bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.66/hraness-ghostget-0.18.66.tgz ghostget read https://example.com ``` @@ -49,9 +49,9 @@ which always names the latest published release. Upgrading from Wrench? Read the The optional Agent Skill teaches your agent when and how to use GhostGet: ```sh -npx skills add hraness/ghostget#v0.18.65 +npx skills add hraness/ghostget#v0.18.66 # With Bun instead: -bunx skills add hraness/ghostget#v0.18.65 +bunx skills add hraness/ghostget#v0.18.66 ``` Start a new agent session, then ask: “Use GhostGet to read https://example.com @@ -183,7 +183,7 @@ firewall. ## Built-in provider catalog -This v0.18.65 source tree supports executable actions for 21 services: Beeper, +This v0.18.66 source tree supports executable actions for 21 services: Beeper, Bluesky, ClasificadosOnline, Facebook, Facebook Groups, Facebook Marketplace, GitHub, Gmail, Hacker News, Instagram, iMessage, LinkedIn, Reddit, Substack, Threads, TikTok, Twitch, WebMCP Registry, WhatsApp, X, and YouTube. LinkedIn @@ -267,7 +267,7 @@ For that same released coordinate, install GhostGet in an agent or application that owns its own model, planning, tool loop, approvals, and interface: ```sh -bun add https://github.com/hraness/ghostget/releases/download/v0.18.65/hraness-ghostget-0.18.65.tgz +bun add https://github.com/hraness/ghostget/releases/download/v0.18.66/hraness-ghostget-0.18.66.tgz ``` ```ts diff --git a/dist/apple-photos-client.js b/dist/apple-photos-client.js index af69dfd3..9d36b13f 100644 --- a/dist/apple-photos-client.js +++ b/dist/apple-photos-client.js @@ -1,7 +1,7 @@ // @bun import { GHOSTGET_VERSION -} from "./index-4wh7rchw.js"; +} from "./index-w01w5jbv.js"; import { canonicalJson, sha256 diff --git a/dist/beeper-client.js b/dist/beeper-client.js index 93519b30..dc97052f 100644 --- a/dist/beeper-client.js +++ b/dist/beeper-client.js @@ -4,7 +4,7 @@ import { } from "./index-26yq8q16.js"; import { GHOSTGET_VERSION -} from "./index-4wh7rchw.js"; +} from "./index-w01w5jbv.js"; import { canonicalJson, canonicalJsonSha256Matches, diff --git a/dist/index-4wh7rchw.js b/dist/index-w01w5jbv.js similarity index 62% rename from dist/index-4wh7rchw.js rename to dist/index-w01w5jbv.js index 34940d51..c09082de 100644 --- a/dist/index-4wh7rchw.js +++ b/dist/index-w01w5jbv.js @@ -1,5 +1,5 @@ // @bun // src/version.ts -var GHOSTGET_VERSION = "0.18.65"; +var GHOSTGET_VERSION = "0.18.66"; export { GHOSTGET_VERSION }; diff --git a/docs/contracts.md b/docs/contracts.md index d8608e1d..fcb587da 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -128,7 +128,7 @@ matches some IDs lists the installed ones and exits 0. { "ok": true, "contract": "ghostget.contract-catalog.v1", - "ghostget": { "version": "0.18.65" }, + "ghostget": { "version": "0.18.66" }, "generatedAt": "2026-09-21T20:00:00.000Z", "vocabulary": { "risks": ["R1", "R2", "R3", "R4"], @@ -186,7 +186,7 @@ does not parse is an error (exit 3) and no catalog is read. { "ok": false, "contract": "ghostget.contract-check.v1", - "ghostget": { "version": "0.18.65" }, + "ghostget": { "version": "0.18.66" }, "plan": { "collectionKey": "hraness-social-profile-statistics", "reads": 15 }, "reads": [ { "index": 0, "accountKey": "x-hraness", "adapter": "x-web", "operation": "profiles.read", diff --git a/docs/publishing.md b/docs/publishing.md index 02d37c91..7a282cc1 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -243,12 +243,12 @@ delivery proceeds through a new source-qualified version. ## Install the canonical release -These commands require the matching published immutable v0.18.65 release. +These commands require the matching published immutable v0.18.66 release. For the CLI: ```sh -bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.65/hraness-ghostget-0.18.65.tgz +bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.66/hraness-ghostget-0.18.66.tgz ghostget --version ghostget doctor --json ``` diff --git a/kb/launch/social-kit.md b/kb/launch/social-kit.md index 2579d125..2a2ce94e 100644 --- a/kb/launch/social-kit.md +++ b/kb/launch/social-kit.md @@ -57,7 +57,7 @@ The plan is for GhostGet to stay small. Your agent does the thinking, and GhostG Post 9 of 9, 192 characters ```text -GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.65. +GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.66. https://ghostget.com/blog/introducing-ghostget/ ``` @@ -115,7 +115,7 @@ The plan is for GhostGet to stay small. Your agent does the thinking, and GhostG Post 9 of 9, 192 characters ```text -GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.65. +GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.66. https://ghostget.com/blog/introducing-ghostget/ ``` @@ -173,7 +173,7 @@ The plan is for GhostGet to stay small. Your agent does the thinking, and GhostG Post 9 of 9, 192 characters ```text -GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.65. +GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.66. https://ghostget.com/blog/introducing-ghostget/ ``` @@ -197,7 +197,7 @@ GhostGet is for Claude Code, Codex, Cursor, and other agents that run commands o The plan is for GhostGet to stay small. Your agent does the thinking, and GhostGet runs only actions someone has reviewed. Each new service arrives as reviewed actions with their own previews. -GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.65. +GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.66. https://ghostget.com/blog/introducing-ghostget/ ``` @@ -224,8 +224,8 @@ Topics: Developer Tools, Artificial Intelligence, Open Source - Sometimes a post goes through but the answer gets lost on the way back. GhostGet writes down every send before it leaves and never sends it again on its own until it knows what happened. - Anything beyond a read starts as a preview that shows the service, the account, and exactly what will be sent. Your agent can prepare it. Nothing is sent until someone confirms that exact preview. - GhostGet is for Claude Code, Codex, Cursor, and other agents that run commands on your Mac or Linux machine and need to read the web and use the accounts you already have. -- GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.65. -- Latest release: v0.18.65. https://ghostget.com/blog/introducing-ghostget/ +- GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.66. +- Latest release: v0.18.66. https://ghostget.com/blog/introducing-ghostget/ ## Beats @@ -251,4 +251,4 @@ Topics: Developer Tools, Artificial Intelligence, Open Source - claimsTotal: 247. verification/claims.json, every claim - claimsEvidenced: 228. verification/claims.json, status evidenced - claimsConfigReadback: 15. verification/claims.json, layer configuration-readback -- status: Latest release: v0.18.65. package.json version +- status: Latest release: v0.18.66. package.json version diff --git a/package.json b/package.json index 8390b3d2..f639128f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hraness/ghostget", - "version": "0.18.65", + "version": "0.18.66", "description": "GhostGet gives your AI agent named web actions: read a page, archive one media item, or use a connected account, without credentials or a browser to steer.", "license": "MIT", "type": "module", diff --git a/scripts/npm-release-workflow.test.ts b/scripts/npm-release-workflow.test.ts index 2f285233..6e548948 100644 --- a/scripts/npm-release-workflow.test.ts +++ b/scripts/npm-release-workflow.test.ts @@ -1220,7 +1220,7 @@ describe("npm publication contract", () => { (MAX_UNPACKED_BYTES + MAX_PACKED_ENTRIES * 1_023 + 1_024) / 512, ) * 512, ); - expect(MAX_PACKAGE_TAR_BYTES).toBe(24_695_808); + expect(MAX_PACKAGE_TAR_BYTES).toBe(24_698_880); expect(MAX_PACKAGE_TAR_BYTES % 512).toBe(0); expect(artifact).toContain("maxOutputLength: MAX_PACKAGE_TAR_BYTES"); expect(artifact).not.toContain("const maximumTarBytes"); @@ -1430,8 +1430,8 @@ describe("npm publication contract", () => { expect(budget).toContain("785b8fa60c329d7ac46bc8fcf4d959b5fa9d96455bba9e7cdea63f6a3827c4f6"); expect(Object.isFrozen(repairPackageMeasurement)).toBeTrue(); expect(repairPackageMeasurement).toMatchObject({ - archiveSha256: "09e69ff231a08b63c399662cdcb0fe9a08c5f5faa563e5e855e2c6b832e8003f", - packedBytes: 12_155_065, unpackedBytes: 24_054_930, entryCount: 625, + archiveSha256: "935cb39bc9c6514a0a629d07d81348656ea1c15e981c4e95d97763c9fb140bc8", + packedBytes: 12_143_979, unpackedBytes: 24_057_665, entryCount: 625, packedPlatformProjection: 12_387, packedPortabilityAllowance: 4_096, payloadPlatformProjection: 353, payloadAllowance: 65, }); @@ -1447,8 +1447,8 @@ describe("npm publication contract", () => { expect(budget).toContain("24,024,705 + 353 + 65 = 24,025,123 unpacked"); expect(budget).toContain("12,152,562 + 12,387 + 4,096 = 12,169,045 packed"); expect(budget).toContain("24,093,786 + 353 + 65 = 24,094,204 unpacked"); - expect(MAX_PACKED_BYTES).toBe(12_171_548); - expect(MAX_PACKED_BYTES).toBe(12_155_065 + 12_387 + 4_096); + expect(MAX_PACKED_BYTES).toBe(12_160_462); + expect(MAX_PACKED_BYTES).toBe(12_143_979 + 12_387 + 4_096); expect(budget).toContain("aa127b3193c9bb3b0cb5deece5927be60ccb7111a50169320d322ffdeaa13f39"); expect(budget).toContain("0c331bab3ab3df69a108e18f5f29845b0db90c281cbd6455c0d90fa0b24081e2"); expect(budget).toContain("873cad8139fda303e2d19c6afd61cf549cf9b4d1d76b2a1d6d632a6afe6bd0d1"); @@ -1569,7 +1569,7 @@ describe("npm publication contract", () => { expect(budget).toContain("47684b3e2eb5cf3ed07fbb520aade8c7251d993f75262fbf1af627d9081a1a5f"); expect(budget).toContain("23,688,277 + 353 + 65 = 23,688,695"); expect(budget).toContain("23,759,283 + 353 + 65 = 23,759,701"); - expect(MAX_UNPACKED_BYTES).toBe(24_055_348); + expect(MAX_UNPACKED_BYTES).toBe(24_058_083); expect(budget).toContain("23,037,873 + 65 = 23,037,938"); expect(budget).toContain("f9f3ab38a682690ceaa2699a7309997512030f0fa500a9dc29dcd108123dc41f"); expect(budget).toContain("23,038,557 + 65 = 23,038,622"); @@ -1602,7 +1602,7 @@ describe("npm publication contract", () => { expect(budget).toContain("01875f12ab73a49d6c7d6bf520dc3d318db816addee2fa7981889f35c958cf7c"); expect(budget).toContain("b12909f08f7c19460ced56e30619f4860a1183f4b0106170c07837dae577a937"); expect(budget).toContain("0b212ac291218528dcf979370110a36f10850e046ca90a536057d9a44e807d1d"); - expect(MAX_UNPACKED_BYTES).toBe(24_054_930 + 353 + 65); + expect(MAX_UNPACKED_BYTES).toBe(24_057_665 + 353 + 65); expect(budget).toContain("22,794,052 + 65 = 22,794,117"); expect(budget).toContain("c482efe748f880e3717727d6d39fd92a68953e6eea766642b329ba47ae772d80"); expect(budget).toContain("22,759,423 + 65 = 22,759,488"); @@ -1638,8 +1638,8 @@ describe("npm publication contract", () => { expect(packageArtifactBudget).toEqual({ entryCount: { min: 625, max: 625 }, fileCount: { min: 625, max: 625 }, - packedBytes: { min: 1_600_000, max: 12_171_548 }, - unpackedBytes: { min: 9_000_000, max: 24_055_348 }, + packedBytes: { min: 1_600_000, max: 12_160_462 }, + unpackedBytes: { min: 9_000_000, max: 24_058_083 }, }); }); diff --git a/scripts/package-budget.ts b/scripts/package-budget.ts index 27bb3ca5..244b3e55 100644 --- a/scripts/package-budget.ts +++ b/scripts/package-budget.ts @@ -2390,21 +2390,21 @@ // Preserve the same projections and allowances: // 12,154,912 + 12,387 + 4,096 = 12,171,395 packed; // 24,054,352 + 353 + 65 = 24,054,770 payload. -// The 0.18.65 editorial release preserves that runtime and file inventory. -// Two byte-identical packs with the same qualified toolchain measured 625 -// entries, 12,155,065 packed and 24,054,930 payload bytes. The 578-byte payload -// increase is the new changelog entry; article and image files are website-only. -// Retain every projection and allowance: -// 12,155,065 + 12,387 + 4,096 = 12,171,548 packed; -// 24,054,930 + 353 + 65 = 24,055,348 payload. +// The 0.18.66 WhatsApp projection repair release retains the 625-entry +// inventory after rebuilding dist. Two byte-identical packs with the same +// qualified toolchain measured 12,143,979 packed and 24,057,665 payload bytes; +// archive SHA-256 935cb39bc9c6514a0a629d07d81348656ea1c15e981c4e95d97763c9fb140bc8. +// Retain the reviewed projections and allowances: +// 12,143,979 + 12,387 + 4,096 = 12,160,462 packed; +// 24,057,665 + 353 + 65 = 24,058,083 payload. export const repairPackageMeasurement = Object.freeze({ - scope: "GhostGet 0.18.65 editorial release over the verified 0.18.64 runtime", + scope: "GhostGet 0.18.66 WhatsApp projection repair release over the verified 0.18.64 runtime", command: "npm pack --ignore-scripts", npmVersion: "11.19.0", platform: "darwin-arm64", - archiveSha256: "09e69ff231a08b63c399662cdcb0fe9a08c5f5faa563e5e855e2c6b832e8003f", - packedBytes: 12_155_065, - unpackedBytes: 24_054_930, + archiveSha256: "935cb39bc9c6514a0a629d07d81348656ea1c15e981c4e95d97763c9fb140bc8", + packedBytes: 12_143_979, + unpackedBytes: 24_057_665, entryCount: 625, packedPlatformProjection: 12_387, packedPortabilityAllowance: 4_096, diff --git a/skills/ghostget/references/install.md b/skills/ghostget/references/install.md index 4fbcfaef..2d5f094d 100644 --- a/skills/ghostget/references/install.md +++ b/skills/ghostget/references/install.md @@ -17,14 +17,14 @@ If Bun is missing, stop and direct the user to the official [Bun installation guide](https://bun.sh/docs/installation). Do not switch package managers or pipe an unreviewed installer into a shell. -This reference is authored for the exact v0.18.65 release coordinate. Use it +This reference is authored for the exact v0.18.66 release coordinate. Use it only from the matching release-bound Agent Skill after its canonical archive and immutable GitHub Release exist. If the coordinate is not public, stop instead of substituting `main`, another tag, or a different package version. Install that exact release and verify a public-page read: ```sh -bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.65/hraness-ghostget-0.18.65.tgz +bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.66/hraness-ghostget-0.18.66.tgz ghostget read https://example.com ``` @@ -47,21 +47,21 @@ for the requested workflow; an unconnected provider or missing media tool does not prevent a public-page read, and overall provider readiness can be false on a fresh installation. -The package is `@hraness/ghostget`; `@hraness/ghostget@0.18.65` is an optional npm +The package is `@hraness/ghostget`; `@hraness/ghostget@0.18.66` is an optional npm mirror only after verified registry publication. Canonical installation does not wait for registry publication. When upgrading from Wrench, use `ghostget` for new commands and `GHOSTGET_STATE_HOME` for an explicit state root. Existing state is selected in place; do not rename, copy, or delete a state directory as part of the upgrade. -The [migration guide](https://github.com/hraness/ghostget/blob/v0.18.65/docs/ghostget-migration.md) +The [migration guide](https://github.com/hraness/ghostget/blob/v0.18.66/docs/ghostget-migration.md) explains the retained state aliases and durable protocol names. Do not clone the repository merely to run the CLI. Importing the SDK is a separate project dependency and does not install a global command: ```sh -bun add https://github.com/hraness/ghostget/releases/download/v0.18.65/hraness-ghostget-0.18.65.tgz +bun add https://github.com/hraness/ghostget/releases/download/v0.18.66/hraness-ghostget-0.18.66.tgz ``` `ghostget adapter sync-bundled` upgrades exact bundled baselines, including an diff --git a/src/beeper-client-types.ts b/src/beeper-client-types.ts index 6b0b9d96..99ff0782 100644 --- a/src/beeper-client-types.ts +++ b/src/beeper-client-types.ts @@ -96,7 +96,7 @@ export type BeeperContactInteractionExportReceipt = Readonly<{ implementation: Readonly<{ producer: Readonly<{ package: "@hraness/ghostget"; - version: "0.18.65"; + version: "0.18.66"; }>; officialCli: Readonly<{ implementation: "github.com/beeper/cli"; diff --git a/src/cli.ts b/src/cli.ts index 2fa23163..dd7e7ea4 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -114,7 +114,7 @@ export function isImmediateGhostgetVersionRequest( && (second === undefined || second === "--json"); } -/** `ghostget 0.18.65`, or `{"name":"ghostget","version":"0.18.65"}` with `--json`. */ +/** `ghostget 0.18.66`, or `{"name":"ghostget","version":"0.18.66"}` with `--json`. */ export function ghostgetVersionText(rawArguments: readonly string[]): string { return rawArguments.includes("--json") ? `${JSON.stringify({ name: "ghostget", version: GHOSTGET_VERSION })}\n` diff --git a/src/contracts-check.test.ts b/src/contracts-check.test.ts index 266e3557..3d34fc38 100644 --- a/src/contracts-check.test.ts +++ b/src/contracts-check.test.ts @@ -35,7 +35,7 @@ describe("checkCollectionPlan", () => { const check = checkCollectionPlan(examplePlan(), exampleCatalog()); expect(check.ok).toBeTrue(); expect(check.contract).toBe("ghostget.contract-check.v1"); - expect(check.ghostget).toEqual({ version: "0.18.65" }); + expect(check.ghostget).toEqual({ version: "0.18.66" }); expect(check.plan).toEqual({ collectionKey: "example-social-statistics", reads: 3 }); expect(check.reads[0]).toEqual({ index: 0, diff --git a/src/contracts.test-support.ts b/src/contracts.test-support.ts index 392677ce..5284de5b 100644 --- a/src/contracts.test-support.ts +++ b/src/contracts.test-support.ts @@ -95,7 +95,7 @@ export function exampleCatalog(): ContractCatalogV1 { return { ok: true, contract: CONTRACT_CATALOG_V1, - ghostget: { version: "0.18.65" }, + ghostget: { version: "0.18.66" }, generatedAt: "2026-09-21T20:00:00.000Z", vocabulary: catalogVocabularyValue, adapters: [ diff --git a/src/media/manifest.test.ts b/src/media/manifest.test.ts index 007018e8..84f233bb 100644 --- a/src/media/manifest.test.ts +++ b/src/media/manifest.test.ts @@ -465,7 +465,7 @@ function trackedYtDlpManifest( describe("Ghostget media manifest", () => { test("uses one Ghostget-owned schema and transcriber identity", () => { expect(GHOSTGET_MEDIA_SCHEMA_VERSION).toBe(1); - expect(GHOSTGET_MEDIA_VERSION).toBe("0.18.65"); + expect(GHOSTGET_MEDIA_VERSION).toBe("0.18.66"); expect(localTranscriptVariantSegments(localIdentity)).toEqual([ "transcript", "local", diff --git a/src/providers/whatsapp-interaction-projection-helper.test.ts b/src/providers/whatsapp-interaction-projection-helper.test.ts index 557fc437..4e3a1e0e 100644 --- a/src/providers/whatsapp-interaction-projection-helper.test.ts +++ b/src/providers/whatsapp-interaction-projection-helper.test.ts @@ -446,6 +446,141 @@ describe("WhatsApp content-free interaction projection helper", () => { } }); + test("maps the short legacy system JID to the fixed unsupported sentinel", async () => { + const path = createStore(); + try { + const database = new Database(join(path, "wacli.db"), { strict: true }); + try { + database.query("INSERT INTO chats(jid, kind) VALUES (?1, ?2)") + .run("1@s.whatsapp.net", "dm"); + database.query(` + INSERT INTO messages(chat_jid,msg_id,sender_jid,ts,from_me) + VALUES (?1,?2,NULL,?3,?4) + `).run("1@s.whatsapp.net", "LEGACY-SYSTEM-1", 1_776_513_603, 0); + } finally { + database.close(); + } + chmodSync(join(path, "wacli.db"), 0o600); + expect(await response(path, request(path, { + cursor: "3", + cursorAnchor: "a86ecbd8b98eb6466c2b584a5b3d3ca0458230bbd6ecc86981d57ca4aaa81830", + limit: 1, + }))).toMatchObject({ + status: "succeeded", + interactions: [{ rowid: "4", chatJid: "0@s.whatsapp.net", chatKind: "unknown" }], + }); + } finally { + rmSync(path, { recursive: true, force: true }); + } + }); + + test("normalizes only known legacy participant fields and retains valid senders", async () => { + const path = createStore(); + try { + const database = new Database(join(path, "wacli.db"), { strict: true }); + try { + database.query(` + INSERT INTO messages(chat_jid,msg_id,sender_jid,ts,from_me) + VALUES + ('15557654321@s.whatsapp.net','LEGACY-DM-OUT','15557654321@s.whatsapp.net',1776513603,1), + ('120363123456789012@g.us','LEGACY-GROUP-OUT','120363123456789012@g.us',1776513604,1), + ('120363123456789012@g.us','LEGACY-GROUP-IN-OWNER',?1,1776513605,0), + ('15557654321@s.whatsapp.net','LEGACY-DM-OUT-DEVICE','15557654321:7@s.whatsapp.net',1776513606,1), + ('120363123456789012@g.us','LEGACY-GROUP-IN-LID','999999999999999:4@lid',1776513607,0), + ('120363123456789012@g.us','VALID-GROUP-IN','15559876543:5@s.whatsapp.net',1776513608,0), + ('15557654321@s.whatsapp.net','VALID-DM-OUT',?1,1776513609,1), + ('120363123456789012@g.us','VALID-GROUP-OUT-LID','999999999999999:4@lid',1776513610,1) + `).run(OWNER_JID); + } finally { + database.close(); + } + chmodSync(join(path, "wacli.db"), 0o600); + expect(await response(path, request(path, { + cursor: "3", + cursorAnchor: "a86ecbd8b98eb6466c2b584a5b3d3ca0458230bbd6ecc86981d57ca4aaa81830", + limit: 8, + }))).toMatchObject({ + status: "succeeded", + interactions: [ + { rowid: "4", messageId: "LEGACY-DM-OUT", senderJid: null }, + { rowid: "5", messageId: "LEGACY-GROUP-OUT", senderJid: null }, + { rowid: "6", messageId: "LEGACY-GROUP-IN-OWNER", senderJid: null }, + { rowid: "7", messageId: "LEGACY-DM-OUT-DEVICE", senderJid: null }, + { rowid: "8", messageId: "LEGACY-GROUP-IN-LID", senderJid: null }, + { rowid: "9", messageId: "VALID-GROUP-IN", senderJid: "15559876543:5@s.whatsapp.net" }, + { rowid: "10", messageId: "VALID-DM-OUT", senderJid: OWNER_JID }, + { rowid: "11", messageId: "VALID-GROUP-OUT-LID", senderJid: "999999999999999:4@lid" }, + ], + }); + } finally { + rmSync(path, { recursive: true, force: true }); + } + }); + + for (const invalid of [ + { label: "outgoing DM participant", chat: "15557654321@s.whatsapp.net", sender: "15559876543@s.whatsapp.net", fromMe: 1 }, + { label: "incoming DM participant", chat: "15557654321@s.whatsapp.net", sender: "15559876543@s.whatsapp.net", fromMe: 0 }, + { label: "outgoing group participant", chat: "120363123456789012@g.us", sender: "15559876543@s.whatsapp.net", fromMe: 1 }, + { label: "outgoing group chat", chat: "120363123456789012@g.us", sender: "120363987654321098@g.us", fromMe: 1 }, + { label: "incoming group chat", chat: "120363123456789012@g.us", sender: "120363987654321098@g.us", fromMe: 0 }, + { label: "incoming group same-chat sender", chat: "120363123456789012@g.us", sender: "120363123456789012@g.us", fromMe: 0 }, + ] as const) { + test(`rejects unrelated or contradictory legacy ${invalid.label}`, async () => { + const path = createStore(); + try { + const database = new Database(join(path, "wacli.db"), { strict: true }); + try { + database.query(` + INSERT INTO messages(chat_jid,msg_id,sender_jid,ts,from_me) + VALUES (?1,'INVALID-LEGACY-SENDER',?2,1776513603,?3) + `).run(invalid.chat, invalid.sender, invalid.fromMe); + } finally { + database.close(); + } + chmodSync(join(path, "wacli.db"), 0o600); + expect(await response(path, request(path, { + cursor: "3", + cursorAnchor: "a86ecbd8b98eb6466c2b584a5b3d3ca0458230bbd6ecc86981d57ca4aaa81830", + limit: 1, + }))).toMatchObject({ status: "failed", errorCode: "projection-invalid" }); + } finally { + rmSync(path, { recursive: true, force: true }); + } + }); + } + + test("rejects unrelated outgoing participant IDs across PN, LID, and device aliases", async () => { + await assertAsyncProperty(fc.asyncProperty( + fc.integer({ min: 10_000, max: 9_999_999 }), + fc.constantFrom("s.whatsapp.net", "lid"), + fc.option(fc.integer({ min: 0, max: 99_999 }), { nil: undefined }), + fc.constantFrom("15557654321@s.whatsapp.net", "120363123456789012@g.us"), + async (participant, server, device, chat) => { + const path = createStore(); + try { + const sender = `${participant}${device === undefined ? "" : `:${device}`}@${server}`; + const database = new Database(join(path, "wacli.db"), { strict: true }); + try { + database.query(` + INSERT INTO messages(chat_jid,msg_id,sender_jid,ts,from_me) + VALUES (?1,'INVALID-LEGACY-PROPERTY',?2,1776513603,1) + `).run(chat, sender); + } finally { + database.close(); + } + chmodSync(join(path, "wacli.db"), 0o600); + expect(await response(path, request(path, { + cursor: "3", + cursorAnchor: "a86ecbd8b98eb6466c2b584a5b3d3ca0458230bbd6ecc86981d57ca4aaa81830", + limit: 1, + }))).toMatchObject({ status: "failed", errorCode: "projection-invalid" }); + } finally { + rmSync(path, { recursive: true, force: true }); + } + }, + ), { numRuns: 20 }); + }); + test("preserves rowids above JavaScript's safe-integer range", async () => { const path = createStore(); try { diff --git a/src/providers/whatsapp-interaction-projection-helper.ts b/src/providers/whatsapp-interaction-projection-helper.ts index 3b3918c6..200a6281 100644 --- a/src/providers/whatsapp-interaction-projection-helper.ts +++ b/src/providers/whatsapp-interaction-projection-helper.ts @@ -62,6 +62,11 @@ const MAX_MESSAGE_DATABASE_BYTES = 2n * 1024n * 1024n * 1024n; const SQLITE_CACHE_KIB = 4_096; const MAX_UNIX_SECONDS = 253_402_300_799n; const SYSTEM_SENTINEL_JID = "0@s.whatsapp.net"; +// Older wacli stores use short numeric JIDs for system rows. They are not +// person or conversation identifiers and must stay outside the contact +// interaction projection. Keep the public projection's single sentinel +// while accepting the bounded legacy spelling. +const LEGACY_SYSTEM_JID = /^[0-9]@s\.whatsapp\.net$/u; const MESSAGE_EXPORT_SESSION_MAX_MESSAGES = 500_000; type MessageExportSessionSelfChatsExcluded = "none-detected" | "present-excluded"; @@ -505,7 +510,7 @@ function sqliteBigInt(value: unknown): bigint { return fail("projection-invalid"); } -function projectedJid(value: unknown, nullable = false): string | null { +function projectedJid(value: unknown, nullable = false, allowLegacySystem = false): string | null { if (nullable && (value === null || value === "")) return null; if ( typeof value !== "string" @@ -513,6 +518,7 @@ function projectedJid(value: unknown, nullable = false): string | null { || value.length > 96 || !( value === SYSTEM_SENTINEL_JID + || (allowLegacySystem && typeof value === "string" && LEGACY_SYSTEM_JID.test(value)) || /^[0-9]{5,20}(?::[0-9]{1,5})?@s\.whatsapp\.net$/u.test(value) || /^[0-9]{5,32}(?::[0-9]{1,5})?@lid$/u.test(value) || /^[0-9]{5,32}(?:-[0-9]{5,20})?@g\.us$/u.test(value) @@ -532,6 +538,43 @@ function legacyInteractionParticipantJid(value: string): string { : `${match[1]}@s.whatsapp.net`; } +function legacyInteractionSender( + item: Readonly<{ + chatJid: string; + chatKind: "dm" | "group" | "broadcast" | "newsletter" | "unknown"; + senderJid: string | null; + fromMe: boolean; + }>, + owner: WhatsAppMatchedOwnerIdentity, +): string | null { + if (item.senderJid === null || (item.chatKind !== "dm" && item.chatKind !== "group")) { + return item.senderJid; + } + if (item.chatKind === "group" && item.senderJid.endsWith("@g.us")) { + // Some outgoing migrated rows carry this exact chat JID in sender_jid. + // Other group JIDs and incoming chat senders remain invalid participants. + return item.fromMe && item.senderJid === item.chatJid ? null : item.senderJid; + } + const sender = legacyInteractionParticipantJid(item.senderJid); + const self = new Set(owner.selfJids); + const chat = item.chatKind === "dm" + ? legacyInteractionParticipantJid(item.chatJid) + : item.chatJid; + if (item.chatKind === "dm" && item.fromMe && sender === chat) { + // Wacli records the recipient in sender_jid for some outgoing direct + // messages. The direction already carries the only fact PeopleBlade + // needs; dropping this inconsistent participant keeps the interaction + // body-free and prevents a false sender identity. + return null; + } + if (item.chatKind === "group" && !item.fromMe && self.has(sender)) { + // Incoming migrated rows may carry an exact session-proved owner alias. + // It identifies no counterparty; unrelated outgoing senders remain invalid. + return null; + } + return item.senderJid; +} + function assertMessageDirection( item: Pick, owner: WhatsAppMatchedOwnerIdentity, @@ -567,12 +610,12 @@ function projectItem( exactRowKeys(row, ["rowid", "chat_jid", "msg_id", "sender_jid", "ts", "from_me", "chat_kind"]); const rowid = sqliteBigInt(row.rowid); const seconds = sqliteBigInt(row.ts); - const fromMe = sqliteBigInt(row.from_me); + const fromMeValue = sqliteBigInt(row.from_me); if ( rowid < 1n || seconds < 0n || seconds > MAX_UNIX_SECONDS - || (fromMe !== 0n && fromMe !== 1n) + || (fromMeValue !== 0n && fromMeValue !== 1n) || typeof row.msg_id !== "string" || !/^[A-Za-z0-9._~:-]{1,256}$/u.test(row.msg_id) || (row.chat_kind !== "dm" @@ -581,14 +624,21 @@ function projectItem( && row.chat_kind !== "newsletter" && row.chat_kind !== "unknown") ) fail("projection-invalid"); - const chatJid = projectedJid(row.chat_jid)! as string; + const rawChatJid = projectedJid(row.chat_jid, false, true)! as string; + const chatJid = LEGACY_SYSTEM_JID.test(rawChatJid) ? SYSTEM_SENTINEL_JID : rawChatJid; + const fromMe = fromMeValue === 1n; const projected = Object.freeze({ rowid: rowid.toString(), chatJid, messageId: row.msg_id, - senderJid: projectedJid(row.sender_jid, true), + senderJid: legacyInteractionSender({ + chatJid, + chatKind: chatJid === SYSTEM_SENTINEL_JID ? "unknown" : row.chat_kind, + senderJid: projectedJid(row.sender_jid, true), + fromMe, + }, owner), timestamp: new Date(Number(seconds) * 1_000).toISOString(), - fromMe: fromMe === 1n, + fromMe, chatKind: chatJid === SYSTEM_SENTINEL_JID ? "unknown" : row.chat_kind, }); assertMessageDirection(projected, owner, "legacy-interaction"); diff --git a/src/version.ts b/src/version.ts index 05c1d69e..5bae5be7 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ /** Canonical immutable Ghostget package release identity. */ -export const GHOSTGET_VERSION = "0.18.65" as const; +export const GHOSTGET_VERSION = "0.18.66" as const; diff --git a/website/build.ts b/website/build.ts index ccbb736a..a89a4765 100644 --- a/website/build.ts +++ b/website/build.ts @@ -136,7 +136,7 @@ export const HRANESS_LOGO_URL = "https://hraness.com/icon.png" as const; export const HRANESS_LINKEDIN_URL = "https://www.linkedin.com/company/hraness" as const; export const NPM_PACKAGE_URL = "https://www.npmjs.com/package/@hraness/ghostget" as const; export const SKILL_REPOSITORY = "hraness/ghostget" as const; -export const CONTENT_REVIEWED_RELEASE = "v0.18.65" as const; +export const CONTENT_REVIEWED_RELEASE = "v0.18.66" as const; export const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com" as const; export const DEMO_PUBLIC_FILES = [ "wrench-first-capture.gif", From 5fd9160316634c5821186884c5ef54307db07dc5 Mon Sep 17 00:00:00 2001 From: 0thernet Date: Thu, 1 Oct 2026 03:02:51 -0400 Subject: [PATCH 2/2] Fix merged 0.18.66 changelog list --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c55ae924..864bea32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,7 @@ Accept the short legacy system JIDs and inconsistent participant fields emitted - Map bounded short system JIDs to the existing unsupported sentinel. - Drop participant fields that contradict the stored message direction instead of failing the complete page. - Keep the strict schema, owner, cursor, and no-message-body guards intact. - -Select the European PostHog UI only for the exact validated ingestion origin. - +- Select the European PostHog UI only for the exact validated ingestion origin. - Replace the ambiguous substring match with exact origin equality. - Preserve fixed trusted PostHog UI URLs.