Add autonomous webhooks and dynamic WebView discovery
This commit is contained in:
1 parent
ab69f287b6
commit
97f874d183
29 files changed
+3044
-1489
No files matched your search
+10
@@ -22,3 +22,13 @@ messages*.json*
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Native compiler outputs and local webhook stores
|
||||
native/build/
|
||||
*.obj
|
||||
*.exp
|
||||
*.lib
|
||||
*.exe
|
||||
*.sqlite3
|
||||
*.sqlite3-*
|
||||
*.db
|
||||
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## Autonomous webhook and dynamic address discovery — 2026-10-11
|
||||
|
||||
- Resolve WebView function/vtable addresses by binary fingerprints instead of fixed module RVAs.
|
||||
- Add a message observer and autonomous native HTTPS/loopback HTTP relay.
|
||||
- Add a durable receiver with deduplication and automatic app/page restart restoration.
|
||||
- Add direct token-authenticated HTTPS delivery and optional per-user Windows task restoration with DPAPI-protected credentials.
|
||||
- Preserve completed native requests when duplicate wake messages arrive.
|
||||
- Document queue/text bounds, native module lifetime and restart requirements.
|
||||
- Add native build recipes and webhook regressions.
|
||||
|
||||
## Compatibility policy update — 2026-10-11
|
||||
|
||||
- Treat the recorded Desktop version and Core DLL hash as tested evidence, rather than an allowlist.
|
||||
|
||||
+1
-1
@@ -4,6 +4,6 @@ The canonical implementation is `whatsapp_suite/`. Edit it rather than the encod
|
||||
|
||||
Use Python 3.11+ and Node.js for the complete offline suite. Optional requirements are in `requirements-whatsapp-optional.txt`. Run source tests, rebuild with `python build_whatsapp_bundle.py`, and run the portable tests before committing. Check that unchanged inputs produce the same bundle hash.
|
||||
|
||||
Native sources are in `native/`; existing verified runtime DLLs are in `whatsapp_suite/assets/`. Changing native code requires rebuilding for Windows x64, updating asset hashes and conducting explicit build-specific live validation. The repository does not yet contain a reproducible native toolchain/build recipe. The Python builder packages existing DLLs and does not compile them.
|
||||
Native sources are in `native/`; existing verified runtime DLLs are in `whatsapp_suite/assets/`. Changing native code requires rebuilding for Windows x64, updating asset hashes and conducting explicit build-specific live validation. Run `native\build.cmd` and `native\build_webhook.cmd` from an x64 MSVC Native Tools prompt to build the helpers (Windows SDK required). The recipes reproduce compilation; native PE bytes are not promised to be bit-identical across toolchain versions or timestamps. Update the Python helper hashes and `docs/runtime-assets.json` after a native rebuild. The Python builder packages existing DLLs and does not compile them.
|
||||
|
||||
Document compatibility changes and distinguish synthetic checks from live checks. Keep send operations explicit, fixed-recipient guards intact, and default tests free of live side effects. Inspect `git diff --cached` and the tracked file list before publication.
|
||||
@@ -13,6 +13,7 @@ The core live commands execute JavaScript inside the signed-in Desktop WebView t
|
||||
| Old message reading | `live --suite history-read` | Five oldest local records, including records outside loaded chats |
|
||||
| Desktop search | `desktop-search` | Bounded app search through the native bridge |
|
||||
| Saved export search | `search` | Unicode-aware literal search, filtering and optional redaction |
|
||||
| Message webhooks | `webhook` | In-process listener, direct HTTPS or loopback delivery, optional automatic restoration |
|
||||
| Self-message test | `send-self` | One fixed message to the signed-in account, with stored-text verification |
|
||||
| Inspection and diagnostics | `inventory`, `stats`, `live` | Storage inventory, export summaries and fixed probes |
|
||||
| Optional research tools | See [CLI reference](docs/CLI.md) | CDP history export, RAM tools, SQLite probes and pairing tools |
|
||||
@@ -29,7 +30,7 @@ python whatsapp.py export-zip --output messages.zip
|
||||
python whatsapp.py live --suite history-read
|
||||
```
|
||||
|
||||
Live bridge commands require **Windows x64 and 64-bit Python**. They were tested with WhatsApp Desktop **2.2639.100.0** and the recorded WebView component. Other versions are attempted with a compatibility warning rather than blocked. The helper still uses native layout offsets from the tested component, so success on other versions is unverified and changed layouts can cause failure or process instability. The core commands need only Python's standard library. Optional tools have [additional dependencies](docs/CLI.md#optional-dependencies); Node.js enables the JavaScript fixture tests.
|
||||
Live bridge commands require **Windows x64 and 64-bit Python**. They were tested with WhatsApp Desktop **2.2639.100.0** and the recorded WebView component. Other versions are attempted with a compatibility warning rather than blocked. The helper discovers function/vtable locations from binary fingerprints rather than fixed module RVAs. It retains compiler/object-layout and ABI assumptions, so other versions remain unverified and incompatible patterns fail discovery. The core commands need only Python's standard library. Optional tools have [additional dependencies](docs/CLI.md#optional-dependencies); Node.js enables the JavaScript fixture tests.
|
||||
|
||||
`send-self` actually sends a message. It is never part of the default tests and does not retry automatically. ZIP exports cover the local database, not necessarily every message ever received. Contacts are local table rows, not a deduplicated phone address book.
|
||||
|
||||
@@ -42,7 +43,7 @@ python build_whatsapp_bundle.py
|
||||
python whatsapp.py test
|
||||
```
|
||||
|
||||
The builder embeds the maintained modules, DLLs and fixtures. Modules load from memory; a verified DLL is extracted only when needed. The distribution is approximately 158 KiB. Keep source and bundle changes together.
|
||||
The builder embeds the maintained modules, DLLs and fixtures. Modules load from memory; a verified DLL is extracted only when needed. The distribution is approximately 207 KiB. Keep source and bundle changes together.
|
||||
|
||||
## Project layout
|
||||
|
||||
@@ -59,6 +60,7 @@ docs/ Architecture, validation and publishing guide
|
||||
## Documentation
|
||||
|
||||
- [CLI and export behavior](docs/CLI.md)
|
||||
- [Message webhooks and restart behavior](docs/WEBHOOKS.md)
|
||||
- [Architecture and native bridge](docs/ARCHITECTURE.md)
|
||||
- [Validation and limitations](docs/VALIDATION.md)
|
||||
- [Development and contributions](CONTRIBUTING.md)
|
||||
|
||||
+5
-1
@@ -2,6 +2,10 @@
|
||||
|
||||
Use the toolkit only with accounts and installations you are authorized to inspect. Live reports, contacts, messages, browser profiles, pairing QR payloads and process dumps can contain private data. Keep them outside Git. The ignore rules are a guardrail; review staged files before upload.
|
||||
|
||||
The native bridge executes inside the Desktop process and uses native layout offsets from a tested build; other versions are attempted with a warning. Use a trusted bundle. An embedded hash detects accidental modification but is not a digital signature. Do not make a CDP endpoint publicly reachable.
|
||||
The native bridge executes inside the Desktop process and discovers native addresses through fingerprints from a tested build while retaining object/ABI assumptions; other versions are attempted with a warning. Use a trusted bundle. An embedded hash detects accidental modification but is not a digital signature. Do not make a CDP endpoint publicly reachable.
|
||||
|
||||
For a sensitive issue, contact the repository owner privately through an established channel. Do not attach live exports, session material or credentials to issues. For ordinary bug reports, include the command, Python/app versions and a redacted diagnostic summary.
|
||||
|
||||
Webhook delivery supports either loopback HTTP or an explicitly configured HTTPS endpoint. Direct delivery validates certificates, disables redirects/proxies and sends a token in `X-Webhook-Token`; the endpoint must validate it. Tokens are read from the named environment variable. Optional Windows task restoration saves a current-user DPAPI-encrypted token, never a plaintext token or credential in task arguments. This configuration is tied to the Windows user/machine, while the Python bundle is portable. Keep it private.
|
||||
|
||||
The built-in loopback receiver does not expose CORS or bind publicly. Its listener-ID check is routing validation, not strong authentication against another local process. Stored webhook events contain plaintext message data and are excluded from Git. Stop the listener with `--config` to disable restoration when finished. Do not expose the loopback receiver directly on the network.
|
||||
@@ -74,7 +74,7 @@ if __name__ == '__main__':
|
||||
|
||||
def main():
|
||||
files={p.relative_to(ROOT).as_posix():p.read_bytes() for p in sorted((ROOT/'whatsapp_suite').glob('*.py'))}
|
||||
for name in ('math_probe.dll','search_probe.dll','sqlite_inject.js'):
|
||||
for name in ('math_probe.dll','search_probe.dll','sqlite_inject.js','webhook_probe.dll','webhook_listener.js'):
|
||||
files['whatsapp_suite/assets/'+name]=(ROOT/'whatsapp_suite/assets'/name).read_bytes()
|
||||
files['whatsapp_suite/_regression.py']=(ROOT/'tests/test_whatsapp_suite.py').read_bytes()
|
||||
for p in sorted((ROOT/'tests').glob('*.cjs')):
|
||||
|
||||
+10
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
The portable launcher verifies and decompresses its embedded payload, then imports the Python package directly from the in-memory ZIP. It uses the same CLI and modules as `python -m whatsapp_suite`.
|
||||
|
||||
For a core live operation, Python uses Windows APIs through `ctypes`. It verifies the helper DLL, records application/WebView compatibility, installs a temporary `WH_GETMESSAGE` hook on the Desktop UI thread, and passes a bounded JavaScript request through shared memory. The native helper locates the existing WebView object using build-specific signatures and calls its `ExecuteScriptAsync` interface. A thread message wakes the hook. Results return through the shared mapping; the hook, mapping and process handles are then released. Reports record cleanup outcomes.
|
||||
For a core live operation, Python uses Windows APIs through `ctypes`. It verifies the helper DLL, records application/WebView compatibility, installs a temporary `WH_GETMESSAGE` hook on the Desktop UI thread, and passes a bounded JavaScript request through shared memory. The native helper discovers function/vtable locations from relocation-free binary fingerprints and locates the existing WebView object using those dynamically resolved addresses and calls its `ExecuteScriptAsync` interface. A thread message wakes the hook. Results return through the shared mapping; the hook, mapping and process handles are then released. Reports record cleanup outcomes.
|
||||
|
||||
This still uses native C++ DLLs. Python packages their bytes; it does not replace their implementation with pure Python. The C++ source is for maintenance and is unnecessary on a machine running the portable file. SHA-256 checks verify embedded asset integrity, not publisher identity or authenticity of a downloaded file.
|
||||
|
||||
@@ -14,7 +14,7 @@ Contact export reads selected public fields from the local contact table. Sendin
|
||||
|
||||
## Boundaries
|
||||
|
||||
Live native operations were tested with Desktop build `2.2639.100.0`. Other versions are attempted with a warning. The helper still uses fixed offsets and native object/method checks derived from the tested WebView DLL. A changed layout may fail discovery or cause process instability; removing the version gate does not make those offsets version-independent. Updates require new evidence and validation to be considered tested. Core bridge commands do not use CDP or UI automation; optional legacy commands have their own transports and dependencies. RAM readers only see loaded models. Exported message text does not include media file downloads. Password or passkey private-key extraction is not an implemented feature.
|
||||
Live native operations were tested with Desktop build `2.2639.100.0`. Other versions are attempted with a warning. The helper resolves function and vtable addresses dynamically, but retains function fingerprints, an object field offset and the exact interface ABI derived from the tested WebView DLL. A changed layout may fail discovery or cause process instability; dynamic address discovery does not make all compiler/object layouts compatible. Updates require new evidence and validation to be considered tested. Core bridge commands do not use CDP or UI automation; optional legacy commands have their own transports and dependencies. RAM readers only see loaded models. Exported message text does not include media file downloads. Password or passkey private-key extraction is not an implemented feature.
|
||||
|
||||
The repository includes only maintained source, synthetic fixtures and required runtime assets. Local profiles, extracted application code, credentials, messages, contacts and research dumps are excluded.
|
||||
|
||||
@@ -25,3 +25,11 @@ The observed implementation uses documented Windows hook loading and WebView scr
|
||||
However, loading a helper into another process and locating internal objects by memory layout is reverse-engineered process instrumentation. WhatsApp does not expose the internal module calls used here as a documented external automation API. Calling documented OS/WebView APIs does not establish that this overall integration is supported or intended by WhatsApp, nor is this observation a complete security audit.
|
||||
|
||||
References: [Microsoft SetWindowsHookExW](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setwindowshookexw), [Microsoft WebView2 JavaScript execution](https://learn.microsoft.com/en-us/microsoft-edge/webview2/how-to/javascript).
|
||||
|
||||
## Autonomous webhook relay
|
||||
|
||||
The explicit `webhook install` command registers JavaScript message collection observers and installs a separate resident helper. A UI-thread timer runs short queue-peek scripts; a WinHTTP worker posts bounded batches to the configured HTTPS endpoint or `127.0.0.1:port/whatsapp-webhook`, without proxies or redirects. HTTPS uses certificate validation and a token header. Queue entries are removed after HTTP 2xx. The built-in loopback receiver acknowledges after SQLite commit and deduplicates message IDs.
|
||||
|
||||
The installer exits and releases its temporary hooks. The webhook helper retains one module reference so native callbacks remain valid; stop cancels the timer and releases WebView references but leaves an idle DLL loaded until app exit. This is intentional and distinct from the short-lived test/export helpers' unload checks.
|
||||
|
||||
App process exit destroys both the injected JavaScript and resident relay. For direct HTTPS, `--auto-restore` registers a per-user Windows task that runs a short `webhook ensure` check every minute. It uses a DPAPI-protected token and does not keep Python or a local server running between checks. Alternatively, a loopback receiver started with `--restore-config` checks every five seconds. Saved configuration persists; injected process memory does not. See [webhook guide](WEBHOOKS.md) for limits.
|
||||
+4
-3
@@ -3,7 +3,7 @@
|
||||
## Portable single-file bundle
|
||||
|
||||
Copy **`whatsapp.py`** to another writable folder. That one Python file includes
|
||||
all tools, both native helpers and the regression fixtures. No adjacent source
|
||||
all tools, the native helpers and the regression fixtures. No adjacent source
|
||||
package, DLLs, C++ compiler or build script is needed to run it.
|
||||
|
||||
```powershell
|
||||
@@ -68,8 +68,8 @@ python -m whatsapp_suite send-self
|
||||
|
||||
Live WebView probes require Windows x64 and 64-bit Python. Desktop build
|
||||
`2.2639.100.0` and its recorded WebView2 Core DLL were tested. Other versions or
|
||||
DLL hashes produce a warning and are attempted. The helper still checks native
|
||||
object signatures and methods before executing JavaScript; changed layouts can
|
||||
DLL hashes produce a warning and are attempted. The helper discovers function and vtable locations from binary fingerprints and checks native
|
||||
objects and methods before executing JavaScript; changed layouts can
|
||||
cause failure or process instability. The JSON report includes detected/tested
|
||||
compatibility metadata and cleanup status. `--pid` selects a Desktop host.
|
||||
|
||||
@@ -90,6 +90,7 @@ Every command supports `--help` for its options.
|
||||
| `analyze` | Storage analysis, optionally using an existing loopback CDP endpoint |
|
||||
| `history-export` | Export local history through Desktop's own database decryption |
|
||||
| `export-zip` | Export all local message rows to ZIP through JavaScript on WebView, without CDP or UI automation |
|
||||
| `webhook` | Install/status/stop, direct HTTPS or loopback relay, ensure/auto-restore and optional watch/serve; see [webhook guide](WEBHOOKS.md) |
|
||||
| `contacts-export` | Export all local contact rows to JSON through the app's JS database getter |
|
||||
| `send-self` | Send one fixed test message to the signed-in account itself and verify stored plaintext |
|
||||
| `ram-read` | Read loaded V8 message objects from process memory |
|
||||
|
||||
@@ -19,3 +19,17 @@ Run `python whatsapp.py test` for portable checks, or `python -m whatsapp_suite
|
||||
## Advisory compatibility update — 2026-10-11
|
||||
|
||||
Source and rebuilt portable bundle each passed 32 offline tests, including acceptance of untested version/hash combinations and recognition of the tested configuration. A portable live arithmetic probe passed on the original tested build, with successful native cleanup. No other installed Desktop version was available for a live check. The optional Frida SQLite agent's version guard is also advisory; its changed metadata/warning path has not been retested live.
|
||||
|
||||
## Dynamic discovery and webhook update — 2026-10-11
|
||||
|
||||
Both rebuilt native helpers passed eight arithmetic cases using dynamic discovery on the existing Core DLL. The offline checker discovered its vtables/function and rejected an unrelated kernel32 DLL. This establishes discovery for one known binary, not cross-version compatibility.
|
||||
|
||||
An installed webhook delivered a self-message via native HTTP after installer exit with exact plaintext equality. The receiver restored the listener after a normal WhatsApp restart into a new PID, and a second self-message matched in SQLite. Receipts and payloads remain in ignored local analysis files. The installer is not kept running; the HTTP receiver is required, and its optional bootstrap reinjects after app restarts.
|
||||
|
||||
Source and isolated portable suites passed 41 tests, including event filtering/decoding/deduplication, bounded text and stop, durable HTTP acknowledgement, duplicate delivery, failure acknowledgement, restoration ownership, direct HTTPS validation, DPAPI token roundtrip and exclusion of transport credentials from listener JavaScript. Direct JavaScript localhost fetch was blocked by connect-src; native HTTP succeeded. No live incoming message from another account or AI summarizer was tested.
|
||||
|
||||
Direct HTTPS delivered two further self-messages to the authenticated Node-RED endpoint. Both message IDs and plaintext SHA-256 hashes matched the send receipts. Between messages, WhatsApp restarted from PID 16340 to 20292; the per-user minute task restored the listener automatically and exited with result 0 using the DPAPI-protected configuration. No local HTTP receiver or continuous Python watcher was running. Stop, disabled ensure and reactivation without an environment token passed. Duplicate native wake messages previously overwrote a successful request; the completed-state guard fixed the observed installation failure. Windows reboot and other Desktop versions were not live tested. The Node-RED test flow routes to an unconnected summarizer link and deduplicates in memory only.
|
||||
|
||||
The final 212,007-byte bundle was also checked live: 313 contact rows exported, five oldest message records read with native cleanup, and Desktop search found the self-test messages. A complete ZIP contained 9,745 message rows with zero decode errors, valid ZIP CRCs and exact plaintext equality for the post-restart test message. Rebuilding unchanged inputs produced the identical SHA-256 `76c28343eb58a93c4b6251bf1981109df25daab02e3d47f2748dfaa34501e890`. The root workspace copy matches this bundle. Optional CDP/Frida/pairing research commands were not all rerun live.
|
||||
|
||||
REA model summary: ev_d05f0cfdcf6fe78605cbe180c38bc8154e306a9b138fd284580cefa995afe683, parent ev_6186f4a795decd53dc3dde7935f9f6dd6bb2d2bb32816e7640842d5d41010d3d. Static coverage has dynamic unknowns; listener behavior is established separately by the live self-message tests.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Message webhooks
|
||||
|
||||
## Execution and restart behavior
|
||||
|
||||
`webhook install` is a one-shot command. JavaScript subscribes to message collection events inside WhatsApp and queues decoded records. A resident native helper posts them directly to an HTTPS endpoint or to a loopback HTTP receiver. Direct HTTPS needs no local server and no continuously running Python process. No CDP or UI automation is used. WhatsApp's CSP blocks direct JavaScript HTTP requests to localhost, so delivery uses native WinHTTP without weakening CSP.
|
||||
|
||||
A WhatsApp process restart destroys injected code. For direct HTTPS, optional `--auto-restore` registers a per-user Windows scheduled task that runs a short restoration check every minute while the user is logged in. No administrator rights are requested. Keep Python, the bundle and configuration at their registered paths. The task uses current-user DPAPI to recover the token without an environment variable. The protected configuration cannot be moved to another Windows user/machine. The listener is automatically reinstalled; process memory does not survive restart. Windows reboot behavior follows the scheduled task's normal logged-in scheduling and was not live tested. WhatsApp's package and startup files are not patched.
|
||||
|
||||
## Start and stop
|
||||
|
||||
For direct HTTPS, supply a 32–128 character printable ASCII token through the named environment variable using your secret manager, then run:
|
||||
|
||||
```powershell
|
||||
python whatsapp.py webhook install --endpoint https://your-endpoint.example/events --token-env WHATSAPP_INJECTOR_WEBHOOK_TOKEN --self-only --auto-restore --config listener.json
|
||||
```
|
||||
|
||||
The endpoint must authenticate `X-Webhook-Token` and accept the batch schema below. Certificate validation is enabled; credentials in URLs, query strings, fragments and redirects are rejected. Remove `--self-only` only when you intend to forward events from other chats. `webhook auto-restore --config listener.json` registers/enables restoration separately; `webhook ensure --config listener.json` performs one check. `webhook watch --config listener.json` is an optional continuously running alternative.
|
||||
|
||||
For a local durable test receiver:
|
||||
|
||||
```powershell
|
||||
python whatsapp.py webhook install --port 18763 --config listener.json
|
||||
python whatsapp.py webhook serve --restore-config listener.json --store events.sqlite3
|
||||
```
|
||||
|
||||
Keep the receiver running; the installer exits. Use `--self-only` on installation when testing just your own self-chat. Otherwise it subscribes to all qualifying local message events. Installation refuses an existing configuration unless you pass `--restore` or choose a new path. The receiver restores the configured listener when WhatsApp starts and does not launch WhatsApp itself.
|
||||
|
||||
```powershell
|
||||
python whatsapp.py webhook status
|
||||
python whatsapp.py send-self --output self-send-receipt.json
|
||||
```
|
||||
|
||||
`send-self` sends a real fixed test message and must be invoked explicitly. It does not retry uncertain sends. The receiver health endpoint is `http://127.0.0.1:18763/health`; it reports counts and restoration state without message content.
|
||||
|
||||
Stop and disable restoration:
|
||||
|
||||
```powershell
|
||||
python whatsapp.py webhook stop --config listener.json
|
||||
```
|
||||
|
||||
Then stop the receiver with Ctrl+C. Re-enable with `webhook install --restore --config listener.json`. Stopping only the injected listener without disabling a restoring configuration causes the receiver to reinstall it.
|
||||
|
||||
## Payload and integration
|
||||
|
||||
The loopback endpoint is `POST /whatsapp-webhook` on `127.0.0.1`; direct HTTPS uses the explicitly configured URL. Batches include `listener_id`, `seqs` and `events`. Each message event includes a sequence number, message ID, chat ID, timestamp, direction, message type, body/caption and text-length/truncation metadata. The built-in receiver persists events in SQLite; an external receiver can feed them to a summarizer. No AI provider or automatic replies are configured.
|
||||
|
||||
HTTP 2xx means the batch was accepted. The built-in receiver commits before acknowledgement and deduplicates message IDs. Failed HTTP delivery leaves queued records available for retry. Consumer side effects such as AI jobs should also deduplicate IDs. There is no exactly-once guarantee.
|
||||
|
||||
## Bounds and limitations
|
||||
|
||||
- This listens to the app's loaded message collection, not a server-side subscription. Messages not surfaced there can be missed.
|
||||
- Reinstallation reconciles qualifying loaded models since the saved install timestamp. It is not a complete offline database catch-up. Receiver IDs deduplicate replays.
|
||||
- Queues are in memory and lost when WhatsApp exits. A full queue of 1,000 events drops new records and exposes a status counter.
|
||||
- Each event includes up to 12,000 body and 4,000 caption UTF-16 code units. Longer text is marked `text_truncated`; full-message ZIP export is unaffected.
|
||||
- Batches contain at most 16 events within a native JSON-size bound. Unusually large escaped records may exceed that bound and stall delivery; oversized records are not silently acknowledged.
|
||||
- Text rows are retried briefly while stored/decoded. Persistent decode failures appear in status counters.
|
||||
- Edits, deletions and media downloads are not separate webhook features; message IDs deduplicate repeated notifications.
|
||||
- Stop cancels the native timer; an idle DLL remains loaded until WhatsApp exits to keep pending callbacks safe.
|
||||
- Auto-restore has a polling/startup gap. Messages during that gap are not guaranteed to be delivered.
|
||||
- Only the recorded Desktop/WebView build was live tested. Address discovery accepts matching fingerprints, not every future compiler or ABI layout.
|
||||
|
||||
## Observed validation
|
||||
|
||||
On 2026-10-11, two self-messages reached the local receiver, including one after receiver-owned restoration following a WhatsApp restart, with exact plaintext equality. Two further self-messages reached the direct HTTPS Node-RED endpoint, including one after the scheduled task restored the listener in a new WhatsApp process. Node-RED message IDs and SHA-256 hashes matched the sent plaintext. The task exited with result 0 and used the protected configuration without an injected environment token. Stop, disabled restoration and reactivation were also checked. These were self-chat tests, not live incoming-message tests from another account. Incoming-event filtering is covered by synthetic fixtures. The Node-RED test flow deduplicates in memory; it is not a durable message archive or an AI summarizer.
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"note": "Runtime asset hashes; integrity only, not a publisher signature",
|
||||
"sha256": {
|
||||
"math_probe.dll": "136620d96f4745a4c41edfe1f4a9272ef25fc060ddace1c0eec1027bf252e02a",
|
||||
"search_probe.dll": "911beb9f6bb52390cde8a75562ca1d6c46f4629f641426a5250286903de60895",
|
||||
"sqlite_inject.js": "61a5d8a9365353ee19ba82d95ded364331e7e69f6705b470c910d6560a22397a"
|
||||
"math_probe.dll": "bdcf0449d35102963c35f0f8ba985ffe3e5f83c083da5587b4978c82911c555f",
|
||||
"search_probe.dll": "f65fc87dff2b3b4b81a46c227659e652e091bfec7f15fff5a1c85c3e10d3a9ef",
|
||||
"sqlite_inject.js": "61a5d8a9365353ee19ba82d95ded364331e7e69f6705b470c910d6560a22397a",
|
||||
"webhook_listener.js": "08fe2a99996cd3ee91d4bc123cc6175d16527224d10ba041a007b0fc68eed880",
|
||||
"webhook_probe.dll": "10acf825aa3c37f356bff6639534db192a5e9babb0402e5d0651f8aecf8c2101"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
@echo off
|
||||
setlocal
|
||||
rem Run from an x64 MSVC Native Tools prompt. Output defaults to native/build.
|
||||
if "%~1"=="" (set "PROBE_OUT=%~dp0build") else (set "PROBE_OUT=%~f1")
|
||||
if not exist "%PROBE_OUT%" mkdir "%PROBE_OUT%"
|
||||
pushd "%PROBE_OUT%"
|
||||
cl /nologo /O2 /MT /LD "%~dp0math_probe.cpp" /Fe:math_probe.dll /link windowsapp.lib user32.lib
|
||||
if errorlevel 1 exit /b 1
|
||||
cl /nologo /O2 /MT /LD "%~dp0search_probe.cpp" /Fe:search_probe.dll /link windowsapp.lib user32.lib
|
||||
if errorlevel 1 exit /b 1
|
||||
cl /nologo /O2 /MT "%~dp0discovery_check.cpp" /Fe:discovery_check.exe
|
||||
if errorlevel 1 exit /b 1
|
||||
popd
|
||||
@@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
setlocal
|
||||
rem Run in an x64 MSVC Native Tools prompt.
|
||||
if "%~1"=="" (set "PROBE_OUT=%~dp0build") else (set "PROBE_OUT=%~f1")
|
||||
if not exist "%PROBE_OUT%" mkdir "%PROBE_OUT%"
|
||||
pushd "%PROBE_OUT%"
|
||||
cl /nologo /O2 /MT /EHsc /std:c++17 /LD "%~dp0webhook_probe.cpp" /Fe:webhook_probe.dll /link windowsapp.lib user32.lib winhttp.lib
|
||||
if errorlevel 1 exit /b 1
|
||||
popd
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
#include <cstring>
|
||||
|
||||
// Conservative fingerprint discovery: no module-relative code/data RVAs.
|
||||
// These relocation-free prefixes describe a supported compiler layout, not
|
||||
// every possible WebView release. Ambiguous/changed layouts fail closed.
|
||||
struct CoreLayout { uintptr_t primary, secondary, query; };
|
||||
static bool discoverCoreLayout(uintptr_t base, CoreLayout& out) {
|
||||
const BYTE query[] = {0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x6c,0x24,0x10,
|
||||
0x48,0x89,0x74,0x24,0x18,0x57,0x48,0x83,0xec,0x20,
|
||||
0x48,0x8d,0x99,0x08,0x01,0x00,0x00,0x33,0xed,0x48,0x85,0xdb};
|
||||
const BYTE execute[] = {0x40,0x53,0x56,0x57,0x41,0x56,0x41,0x57,
|
||||
0x48,0x83,0xec,0x30,0x4d,0x8b,0xf0,0x45,0x33,0xff,0x4d,0x89,0x38,
|
||||
0x48,0x8d,0x71,0xf0};
|
||||
auto dos=(IMAGE_DOS_HEADER*)base;
|
||||
if(dos->e_magic!=IMAGE_DOS_SIGNATURE || dos->e_lfanew<=0 || dos->e_lfanew>0x100000)return false;
|
||||
auto nt=(IMAGE_NT_HEADERS64*)(base+dos->e_lfanew);
|
||||
if(nt->Signature!=IMAGE_NT_SIGNATURE || nt->FileHeader.Machine!=IMAGE_FILE_MACHINE_AMD64 ||
|
||||
nt->OptionalHeader.Magic!=IMAGE_NT_OPTIONAL_HDR64_MAGIC || nt->FileHeader.NumberOfSections>96)return false;
|
||||
auto sections=IMAGE_FIRST_SECTION(nt);
|
||||
unsigned nq=0,ne=0,np=0;
|
||||
for(unsigned i=0;i<nt->FileHeader.NumberOfSections;i++) {
|
||||
auto& s=sections[i];size_t size=s.Misc.VirtualSize;
|
||||
if(s.VirtualAddress>=nt->OptionalHeader.SizeOfImage || size>nt->OptionalHeader.SizeOfImage-s.VirtualAddress)return false;
|
||||
if(!(s.Characteristics&IMAGE_SCN_MEM_EXECUTE))continue;
|
||||
const BYTE* start=(BYTE*)(base+s.VirtualAddress);
|
||||
for(size_t j=0;j+sizeof(query)<=size;j++)if(!memcmp(start+j,query,sizeof(query))){out.query=(uintptr_t)(start+j);nq++;}
|
||||
for(size_t j=0;j+sizeof(execute)<=size;j++)if(!memcmp(start+j,execute,sizeof(execute)))ne++;
|
||||
}
|
||||
if(nq!=1 || ne==0)return false;
|
||||
for(unsigned i=0;i<nt->FileHeader.NumberOfSections;i++) {
|
||||
auto& s=sections[i];size_t size=s.Misc.VirtualSize;
|
||||
if(!(s.Characteristics&IMAGE_SCN_MEM_READ) || (s.Characteristics&(IMAGE_SCN_MEM_WRITE|IMAGE_SCN_MEM_EXECUTE)))continue;
|
||||
auto start=base+s.VirtualAddress;
|
||||
for(size_t j=0;j+sizeof(uintptr_t)*6<=size;j+=sizeof(uintptr_t)) {
|
||||
auto v=(uintptr_t*)(start+j);
|
||||
if(v[0]==out.query){out.primary=(uintptr_t)v;np++;}
|
||||
}
|
||||
}
|
||||
// The secondary table immediately precedes this implementation's primary
|
||||
// table (eight ABI entries). Keep this structural requirement explicit.
|
||||
if(np!=1 || out.primary<base+8*sizeof(uintptr_t))return false;
|
||||
out.secondary=out.primary-8*sizeof(uintptr_t);
|
||||
for(unsigned i=0;i<6;i++) {
|
||||
auto target=((uintptr_t*)out.secondary)[i];
|
||||
if(target<base || target>=base+nt->OptionalHeader.SizeOfImage)return false;
|
||||
MEMORY_BASIC_INFORMATION m{};
|
||||
if(!VirtualQuery((void*)target,&m,sizeof(m)) || m.State!=MEM_COMMIT ||
|
||||
(m.Protect!=PAGE_EXECUTE_READ && m.Protect!=PAGE_EXECUTE_READWRITE))return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool supportedExecute(void* code) {
|
||||
const BYTE prefix[]={0x40,0x53,0x56,0x57,0x41,0x56,0x41,0x57,
|
||||
0x48,0x83,0xec,0x30,0x4d,0x8b,0xf0,0x45,0x33,0xff,0x4d,0x89,0x38,
|
||||
0x48,0x8d,0x71,0xf0};
|
||||
MEMORY_BASIC_INFORMATION m{};
|
||||
if(!VirtualQuery(code,&m,sizeof(m)) || m.State!=MEM_COMMIT ||
|
||||
(m.Protect!=PAGE_EXECUTE_READ && m.Protect!=PAGE_EXECUTE_READWRITE))return false;
|
||||
return memcmp(code,prefix,sizeof(prefix))==0;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include "core_discovery.h"
|
||||
int wmain(int argc,wchar_t** argv) {
|
||||
if(argc!=2)return 2;
|
||||
HMODULE h=LoadLibraryExW(argv[1],nullptr,DONT_RESOLVE_DLL_REFERENCES);
|
||||
if(!h)return 3;
|
||||
CoreLayout l{};bool ok=discoverCoreLayout((uintptr_t)h,l);
|
||||
if(ok)printf("primary=%llx secondary=%llx query=%llx\n",
|
||||
l.primary-(uintptr_t)h,l.secondary-(uintptr_t)h,l.query-(uintptr_t)h);
|
||||
FreeLibrary(h);return ok?0:4;
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <wchar.h>
|
||||
#include "core_discovery.h"
|
||||
struct Packet { volatile LONG state; LONG hr; DWORD thread; DWORD protocol;
|
||||
wchar_t script[8192]; wchar_t result[32768]; };
|
||||
static const UINT Wake = WM_APP + 0x498;
|
||||
@@ -41,6 +42,8 @@ static void fail(HRESULT hr) { clearAsync();packet->hr=hr;InterlockedExchange(&p
|
||||
static HRESULT findCore() {
|
||||
auto module=(uintptr_t)GetModuleHandleW(L"Microsoft.Web.WebView2.Core.dll");
|
||||
if(!module)return HRESULT_FROM_WIN32(ERROR_MOD_NOT_FOUND);
|
||||
CoreLayout layout{};
|
||||
if(!discoverCoreLayout(module,layout))return HRESULT_FROM_WIN32(ERROR_REVISION_MISMATCH);
|
||||
MEMORY_BASIC_INFORMATION mbi;
|
||||
uintptr_t address=0,scanned=0;
|
||||
while(VirtualQuery((void*)address,&mbi,sizeof(mbi))) {
|
||||
@@ -52,11 +55,11 @@ static HRESULT findCore() {
|
||||
if(scanned+mbi.RegionSize>256*1024*1024)break;
|
||||
scanned+=mbi.RegionSize;
|
||||
for(uintptr_t p=(uintptr_t)mbi.BaseAddress;p+0x118<=end;p+=8) {
|
||||
if(*(uintptr_t*)p!=module+0xa6ef0 || *(uintptr_t*)(p+0x108)!=module+0xa6eb0)continue;
|
||||
if(table((void*)p)[0]!=(void*)(module+0x5ac50))continue;
|
||||
if(*(uintptr_t*)p!=layout.primary || *(uintptr_t*)(p+0x108)!=layout.secondary)continue;
|
||||
if(table((void*)p)[0]!=(void*)(layout.query))continue;
|
||||
auto hr=((IUnknown*)p)->QueryInterface(CoreIID,(void**)&core);
|
||||
if(FAILED(hr))continue;
|
||||
if(table(core)[49]!=(void*)(module+0x59060)) { core->Release();core=nullptr;continue; }
|
||||
if(!supportedExecute(table(core)[49])) { core->Release();core=nullptr;continue; }
|
||||
return S_OK;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <wchar.h>
|
||||
#include "core_discovery.h"
|
||||
struct Packet { volatile LONG state; LONG hr; DWORD thread; DWORD reserved;
|
||||
wchar_t script[8192]; wchar_t result[32768]; };
|
||||
static const UINT Wake = WM_APP + 0x497;
|
||||
@@ -25,6 +26,8 @@ static void fail(HRESULT hr) { clearAsync();packet->hr=hr;InterlockedExchange(&p
|
||||
static HRESULT findCore() {
|
||||
auto module=(uintptr_t)GetModuleHandleW(L"Microsoft.Web.WebView2.Core.dll");
|
||||
if(!module)return HRESULT_FROM_WIN32(ERROR_MOD_NOT_FOUND);
|
||||
CoreLayout layout{};
|
||||
if(!discoverCoreLayout(module,layout))return HRESULT_FROM_WIN32(ERROR_REVISION_MISMATCH);
|
||||
MEMORY_BASIC_INFORMATION mbi;
|
||||
uintptr_t address=0,scanned=0;
|
||||
while(VirtualQuery((void*)address,&mbi,sizeof(mbi))) {
|
||||
@@ -36,11 +39,11 @@ static HRESULT findCore() {
|
||||
if(scanned+mbi.RegionSize>256*1024*1024)break;
|
||||
scanned+=mbi.RegionSize;
|
||||
for(uintptr_t p=(uintptr_t)mbi.BaseAddress;p+0x118<=end;p+=8) {
|
||||
if(*(uintptr_t*)p!=module+0xa6ef0 || *(uintptr_t*)(p+0x108)!=module+0xa6eb0)continue;
|
||||
if(table((void*)p)[0]!=(void*)(module+0x5ac50))continue;
|
||||
if(*(uintptr_t*)p!=layout.primary || *(uintptr_t*)(p+0x108)!=layout.secondary)continue;
|
||||
if(table((void*)p)[0]!=(void*)(layout.query))continue;
|
||||
auto hr=((IUnknown*)p)->QueryInterface(CoreIID,(void**)&core);
|
||||
if(FAILED(hr))continue;
|
||||
if(table(core)[49]!=(void*)(module+0x59060)) { core->Release();core=nullptr;continue; }
|
||||
if(!supportedExecute(table(core)[49])) { core->Release();core=nullptr;continue; }
|
||||
return S_OK;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
#include <winhttp.h>
|
||||
#include <inspectable.h>
|
||||
#include <winstring.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <wchar.h>
|
||||
#include <string>
|
||||
#include "core_discovery.h"
|
||||
struct Packet { volatile LONG state; LONG hr; DWORD thread,protocol; wchar_t script[8192],result[32768]; };
|
||||
static const UINT Wake=WM_APP+0x499;
|
||||
static const GUID CoreIID={0x3a3f559a,0xe5e9,0x5338,{0xbb,0x67,0x4e,0xb0,0x50,0x4a,0x4f,0x14}};
|
||||
static const GUID InfoIID={0x00000036,0,0,{0xc0,0,0,0,0,0,0,0x46}};
|
||||
static IUnknown *core=nullptr,*operation=nullptr,*info=nullptr;
|
||||
static UINT_PTR timer=0;
|
||||
static HMODULE resident=nullptr;
|
||||
static DWORD uiThread=0;
|
||||
static unsigned short port=0;
|
||||
static bool secure=false;
|
||||
static std::wstring endpoint,hostname=L"127.0.0.1",path=L"/whatsapp-webhook",token;
|
||||
static volatile LONG wipeAfterWorker=0;
|
||||
static HANDLE worker=nullptr;
|
||||
static volatile LONG httpOK=0;
|
||||
static ULONG delivered=0,attempts=0;
|
||||
static std::wstring body,ack;
|
||||
static bool acknowledging=false;
|
||||
static ULONGLONG operationStarted=0;
|
||||
static void** table(void* p){return *(void***)p;}
|
||||
static void wipe(std::wstring& value){if(!value.empty())SecureZeroMemory(value.data(),value.size()*sizeof(wchar_t));value.clear();}
|
||||
struct Destination {unsigned short port=0;bool secure=false;std::wstring endpoint,host,path,token;~Destination(){wipe(token);}};
|
||||
static bool parseDestination(const wchar_t* text,Destination& out){
|
||||
const wchar_t* split=wcschr(text,L'\n');
|
||||
if(!split){wchar_t* end=nullptr;auto value=wcstoul(text,&end,10);if(!value||value>65535||!end||*end)return false;out.port=(unsigned short)value;out.host=L"127.0.0.1";out.path=L"/whatsapp-webhook";return true;}
|
||||
out.endpoint.assign(text,split-text);out.token.assign(split+1);
|
||||
if(out.token.size()<32||out.token.size()>128)return false;
|
||||
for(auto ch:out.token)if(ch<33||ch>126)return false;
|
||||
if(out.endpoint.find_first_of(L"\r\n?#")!=std::wstring::npos)return false;
|
||||
URL_COMPONENTS url{};url.dwStructSize=sizeof(url);url.dwHostNameLength=url.dwUrlPathLength=url.dwUserNameLength=url.dwPasswordLength=url.dwExtraInfoLength=(DWORD)-1;
|
||||
if(!WinHttpCrackUrl(out.endpoint.c_str(),(DWORD)out.endpoint.size(),0,&url)||url.nScheme!=INTERNET_SCHEME_HTTPS||!url.dwHostNameLength||url.dwUserNameLength||url.dwPasswordLength||url.dwExtraInfoLength)return false;
|
||||
out.secure=true;out.port=url.nPort;out.host.assign(url.lpszHostName,url.dwHostNameLength);out.path.assign(url.lpszUrlPath,url.dwUrlPathLength);
|
||||
return !out.path.empty()&&out.path[0]==L'/';
|
||||
}
|
||||
static void clearAsync(){
|
||||
if(info){using Action=HRESULT(STDMETHODCALLTYPE*)(void*);((Action)table(info)[9])(info);((Action)table(info)[10])(info);info->Release();info=nullptr;}
|
||||
if(operation){operation->Release();operation=nullptr;}
|
||||
}
|
||||
static HRESULT findCore(){
|
||||
auto module=(uintptr_t)GetModuleHandleW(L"Microsoft.Web.WebView2.Core.dll");
|
||||
CoreLayout layout{};
|
||||
if(!module||!discoverCoreLayout(module,layout))return HRESULT_FROM_WIN32(ERROR_REVISION_MISMATCH);
|
||||
MEMORY_BASIC_INFORMATION mbi;uintptr_t address=0,scanned=0;
|
||||
while(VirtualQuery((void*)address,&mbi,sizeof(mbi))){
|
||||
uintptr_t end=(uintptr_t)mbi.BaseAddress+mbi.RegionSize;if(end<=address)break;address=end;
|
||||
if(mbi.State!=MEM_COMMIT||mbi.Type!=MEM_PRIVATE||mbi.Protect!=PAGE_READWRITE||mbi.RegionSize>64*1024*1024)continue;
|
||||
if(scanned+mbi.RegionSize>256*1024*1024)break;scanned+=mbi.RegionSize;
|
||||
for(uintptr_t p=(uintptr_t)mbi.BaseAddress;p+0x118<=end;p+=8){
|
||||
if(*(uintptr_t*)p!=layout.primary||*(uintptr_t*)(p+0x108)!=layout.secondary||table((void*)p)[0]!=(void*)layout.query)continue;
|
||||
if(FAILED(((IUnknown*)p)->QueryInterface(CoreIID,(void**)&core)))continue;
|
||||
if(!supportedExecute(table(core)[49])){core->Release();core=nullptr;continue;}return S_OK;
|
||||
}
|
||||
}return HRESULT_FROM_WIN32(ERROR_NOT_FOUND);
|
||||
}
|
||||
static HRESULT execute(const std::wstring& script){
|
||||
HSTRING text=nullptr;auto hr=WindowsCreateString(script.c_str(),(UINT32)script.size(),&text);
|
||||
if(FAILED(hr))return hr;
|
||||
using Execute=HRESULT(STDMETHODCALLTYPE*)(void*,HSTRING,IUnknown**);
|
||||
hr=((Execute)table(core)[49])(core,text,&operation);WindowsDeleteString(text);
|
||||
if(SUCCEEDED(hr))hr=operation->QueryInterface(InfoIID,(void**)&info);
|
||||
if(FAILED(hr))clearAsync();else operationStarted=GetTickCount64();return hr;
|
||||
}
|
||||
static DWORD WINAPI post(void*){
|
||||
int count=WideCharToMultiByte(CP_UTF8,WC_ERR_INVALID_CHARS,body.c_str(),(int)body.size(),nullptr,0,nullptr,nullptr);
|
||||
if(count<=0||count>131072)return 0;
|
||||
std::string bytes(count,'\0');WideCharToMultiByte(CP_UTF8,WC_ERR_INVALID_CHARS,body.c_str(),(int)body.size(),bytes.data(),count,nullptr,nullptr);
|
||||
HINTERNET session=WinHttpOpen(L"WhatsAppLocalWebhook/1",WINHTTP_ACCESS_TYPE_NO_PROXY,nullptr,nullptr,0);
|
||||
HINTERNET connection=nullptr,request=nullptr;
|
||||
if(session){WinHttpSetTimeouts(session,2000,2000,2000,2000);connection=WinHttpConnect(session,hostname.c_str(),port,0);}
|
||||
if(connection)request=WinHttpOpenRequest(connection,L"POST",path.c_str(),nullptr,nullptr,nullptr,secure?WINHTTP_FLAG_SECURE:0);
|
||||
DWORD policy=WINHTTP_OPTION_REDIRECT_POLICY_NEVER;
|
||||
if(request){WinHttpSetOption(request,WINHTTP_OPTION_REDIRECT_POLICY,&policy,sizeof(policy));
|
||||
std::wstring headers=L"Content-Type: application/json\r\n";if(secure)headers+=L"X-Webhook-Token: "+token+L"\r\n";
|
||||
if(WinHttpSendRequest(request,headers.c_str(),(DWORD)headers.size(),bytes.data(),count,count,0)&&WinHttpReceiveResponse(request,nullptr)){
|
||||
DWORD code=0,size=sizeof(code);if(WinHttpQueryHeaders(request,WINHTTP_QUERY_STATUS_CODE|WINHTTP_QUERY_FLAG_NUMBER,nullptr,&code,&size,nullptr)&&code>=200&&code<300)InterlockedExchange(&httpOK,1);
|
||||
}wipe(headers);WinHttpCloseHandle(request);
|
||||
}if(connection)WinHttpCloseHandle(connection);if(session)WinHttpCloseHandle(session);
|
||||
if(InterlockedExchange(&wipeAfterWorker,0))wipe(token);return 0;
|
||||
}
|
||||
static void tick(){
|
||||
if(!timer)return; // KillTimer may leave an already queued callback.
|
||||
if(worker){if(WaitForSingleObject(worker,0)!=WAIT_OBJECT_0)return;CloseHandle(worker);worker=nullptr;
|
||||
if(InterlockedExchange(&httpOK,0)){++delivered;acknowledging=true;if(FAILED(execute(ack)))acknowledging=false;return;}body.clear();ack.clear();}
|
||||
if(operation){using Status=HRESULT(STDMETHODCALLTYPE*)(void*,INT32*);INT32 status=0;
|
||||
if(FAILED(((Status)table(info)[7])(info,&status))||status>1||GetTickCount64()-operationStarted>10000){clearAsync();acknowledging=false;return;}
|
||||
if(status==0)return;
|
||||
using Results=HRESULT(STDMETHODCALLTYPE*)(void*,HSTRING*);HSTRING value=nullptr;
|
||||
auto hr=((Results)table(operation)[8])(operation,&value);std::wstring result;
|
||||
if(SUCCEEDED(hr)){UINT32 length=0;auto raw=WindowsGetStringRawBuffer(value,&length);if(length<=32768)result.assign(raw,length);WindowsDeleteString(value);}clearAsync();
|
||||
if(acknowledging){acknowledging=false;body.clear();ack.clear();return;}
|
||||
// Script returns an object, so WebView returns JSON directly, not an escaped JSON string.
|
||||
auto start=result.find(L"\"seqs\":[");if(start==std::wstring::npos)return;start+=8;auto end=result.find(L']',start);
|
||||
if(end==std::wstring::npos||end==start)return;
|
||||
auto seqs=result.substr(start,end-start);if(seqs.find_first_not_of(L"0123456789,")!=std::wstring::npos)return;
|
||||
body=result;ack=L"(()=>{const w=window.__whatsappWebhookV1;if(!w)return false;const ids=new Set(["+seqs+L"]);const before=w.queue.length;w.queue=w.queue.filter(e=>!ids.has(e.seq));w.delivered=(w.delivered||0)+before-w.queue.length;w.last_error=null;return true;})()";
|
||||
++attempts;worker=CreateThread(nullptr,0,post,nullptr,0,nullptr);return;
|
||||
}
|
||||
execute(L"(()=>{const w=window.__whatsappWebhookV1;if(!w||w.stopped)return {seqs:[],events:[]};const events=[];for(const e of w.queue.slice(0,16)){if(JSON.stringify([...events,e]).length>30000)break;events.push(e);}return {listener_id:w.listener_id,seqs:events.map(e=>e.seq),events};})()");
|
||||
}
|
||||
static void CALLBACK timerProc(HWND,UINT,UINT_PTR,DWORD){__try{tick();}__except(EXCEPTION_EXECUTE_HANDLER){clearAsync();}}
|
||||
static void command(){
|
||||
wchar_t name[96];swprintf_s(name,L"Local\\WhatsAppWebhook-v1-%lu",GetCurrentProcessId());
|
||||
HANDLE mapping=OpenFileMappingW(FILE_MAP_ALL_ACCESS,FALSE,name);if(!mapping)return;
|
||||
Packet* packet=(Packet*)MapViewOfFile(mapping,FILE_MAP_ALL_ACCESS,0,0,sizeof(Packet));if(!packet){CloseHandle(mapping);return;}
|
||||
HRESULT hr=S_OK;LONG result=-1;
|
||||
if(packet->protocol!=0x57484231||packet->thread!=GetCurrentThreadId())hr=E_INVALIDARG;
|
||||
else if(packet->state==0){
|
||||
if(worker&&WaitForSingleObject(worker,0)==WAIT_OBJECT_0){CloseHandle(worker);worker=nullptr;body.clear();ack.clear();InterlockedExchange(&httpOK,0);}
|
||||
packet->script[8191]=0;Destination destination;
|
||||
bool parsed=parseDestination(packet->script,destination);SecureZeroMemory(packet->script,sizeof(packet->script));
|
||||
if(!parsed)hr=E_INVALIDARG;
|
||||
else if(timer){if(port!=destination.port||secure!=destination.secure||endpoint!=destination.endpoint||token!=destination.token)hr=HRESULT_FROM_WIN32(ERROR_BUSY);}
|
||||
else if(worker)hr=HRESULT_FROM_WIN32(ERROR_BUSY);
|
||||
else{if(!core)hr=findCore();if(SUCCEEDED(hr)){
|
||||
if(!resident)GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS,(LPCWSTR)&command,&resident);
|
||||
if(!resident)hr=HRESULT_FROM_WIN32(GetLastError());else{wipe(token);port=destination.port;secure=destination.secure;endpoint=destination.endpoint;hostname=destination.host;path=destination.path;token=destination.token;uiThread=GetCurrentThreadId();timer=SetTimer(nullptr,0,500,timerProc);if(!timer){hr=HRESULT_FROM_WIN32(GetLastError());wipe(token);}}
|
||||
}}result=2;
|
||||
}else if(packet->state==6){if(timer){KillTimer(nullptr,timer);timer=0;}clearAsync();if(core){core->Release();core=nullptr;}
|
||||
if(worker&&WaitForSingleObject(worker,0)!=WAIT_OBJECT_0){InterlockedExchange(&wipeAfterWorker,1);if(WaitForSingleObject(worker,0)==WAIT_OBJECT_0){InterlockedExchange(&wipeAfterWorker,0);wipe(token);}}
|
||||
else wipe(token);result=7;
|
||||
// Keep one module reference until process exit: a pending worker/callback may still execute.
|
||||
}else if(packet->state==3)result=4;
|
||||
else if(packet->state==2||packet->state==4||packet->state==7||packet->state==-1){
|
||||
// The controller may queue several wake messages while discovery is running.
|
||||
// Completed requests must retain their result when those messages arrive.
|
||||
UnmapViewOfFile(packet);CloseHandle(mapping);return;
|
||||
}else hr=E_INVALIDARG;
|
||||
swprintf_s(packet->result,L"{\"running\":%s,\"port\":%u,\"delivered_batches\":%lu,\"attempts\":%lu}",timer?L"true":L"false",port,delivered,attempts);
|
||||
packet->hr=hr;InterlockedExchange(&packet->state,FAILED(hr)?-1:result);UnmapViewOfFile(packet);CloseHandle(mapping);
|
||||
}
|
||||
extern "C" __declspec(dllexport) LRESULT CALLBACK ProbeHook(int code,WPARAM wParam,LPARAM lParam){
|
||||
if(code>=0&&wParam==PM_REMOVE&&((MSG*)lParam)->message==Wake){((MSG*)lParam)->message=WM_NULL;__try{command();}__except(EXCEPTION_EXECUTE_HANDLER){}}
|
||||
return CallNextHookEx(nullptr,code,wParam,lParam);
|
||||
}
|
||||
BOOL WINAPI DllMain(HINSTANCE,DWORD,LPVOID){return TRUE;}
|
||||
#ifdef WEBHOOK_CONFIG_TEST
|
||||
int main(){
|
||||
const wchar_t* valid[]={L"8765",L"https://example.test/events\n0123456789abcdef0123456789abcdef",L"https://example.test:8443/events\n0123456789abcdef0123456789abcdef"};
|
||||
const wchar_t* invalid[]={L"0",L"65536",L"8765x",L"http://example.test/events\n0123456789abcdef0123456789abcdef",L"https://user:[email protected]/events\n0123456789abcdef0123456789abcdef",L"https://example.test/events?secret=x\n0123456789abcdef0123456789abcdef",L"https://example.test/events#fragment\n0123456789abcdef0123456789abcdef",L"https://example.test/events\nshort",L"https://example.test/events\n0123456789abcdef0123456789abc\rdef",L"https://example.test/events\n0123456789abcdef0123456789abc\ndef"};
|
||||
for(auto text:valid){Destination destination;if(!parseDestination(text,destination))return 1;}
|
||||
for(auto text:invalid){Destination destination;if(parseDestination(text,destination))return 2;}
|
||||
Destination remote;if(!parseDestination(valid[2],remote)||remote.port!=8443||!remote.secure||remote.host!=L"example.test"||remote.path!=L"/events")return 3;
|
||||
puts("14 synthetic configuration checks passed");return 0;
|
||||
}
|
||||
#endif
|
||||
@@ -311,3 +311,141 @@ class DesktopCompatibilityTests(unittest.TestCase):
|
||||
result=desktop.check_core_build(path)
|
||||
self.assertTrue(result['matches_tested_configuration'])
|
||||
self.assertIsNone(result['warning'])
|
||||
|
||||
|
||||
class WebhookTests(unittest.TestCase):
|
||||
def test_listener_javascript_does_not_receive_transport_credentials(self):
|
||||
from whatsapp_suite import webhook
|
||||
config={'version':1,'endpoint':'https://example.test/events','token_env':'TEST_TOKEN',
|
||||
'protected_token':'synthetic-protected-credential','self_only':True,'since_ms':1,
|
||||
'listener_id':'91b17841-2106-4e73-97db-c38be6e9b269'}
|
||||
def run(pid,suite,_operation):
|
||||
report={'pid':1};_operation(None,None,report);return report
|
||||
with patch.object(webhook,'run_math',side_effect=run), \
|
||||
patch.object(webhook,'evaluate',return_value={'installed':True}) as evaluate, \
|
||||
patch.object(webhook,'native_control',return_value={'running':True}), \
|
||||
patch.object(webhook,'configuration_token',return_value='synthetic-token'):
|
||||
webhook.manage('install',config)
|
||||
script=evaluate.call_args.args[1]
|
||||
self.assertNotIn(config['protected_token'],script)
|
||||
self.assertNotIn(config['token_env'],script)
|
||||
self.assertNotIn(config['endpoint'],script)
|
||||
self.assertIn(config['listener_id'],script)
|
||||
|
||||
def test_listener_filters_decodes_deduplicates_and_stops(self):
|
||||
if not shutil.which('node'):
|
||||
self.skipTest('Node.js unavailable')
|
||||
fixture=resource_bytes('tests/webhook_listener.cjs').decode('utf-8')
|
||||
source=asset_bytes('webhook_listener.js').decode('utf-8')
|
||||
# Node receives its fixture as argv code and the real producer through stdin.
|
||||
result=subprocess.run(['node','-e',fixture],input=source,capture_output=True,
|
||||
text=True,encoding='utf-8',timeout=10)
|
||||
self.assertEqual(result.returncode,0,result.stderr)
|
||||
|
||||
def test_receiver_acknowledges_committed_events_and_deduplicates(self):
|
||||
import threading
|
||||
import urllib.request
|
||||
import sqlite3
|
||||
from whatsapp_suite.webhook import make_server
|
||||
listener='91b17841-2106-4e73-97db-c38be6e9b269'
|
||||
payload={'listener_id':listener,'events':[{'seq':1,'event_id':'synthetic-1',
|
||||
'type':'message','message':{'id':'synthetic-1','body':'Grüße 🐈'}}]}
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
store=Path(folder)/'events.sqlite3'
|
||||
server=make_server(0,store,listener)
|
||||
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
|
||||
try:
|
||||
url='http://127.0.0.1:'+str(server.server_port)+'/whatsapp-webhook'
|
||||
results=[]
|
||||
for _ in range(2):
|
||||
request=urllib.request.Request(url,data=json.dumps(payload).encode(),
|
||||
headers={'Content-Type':'application/json'})
|
||||
with urllib.request.urlopen(request,timeout=3) as response:
|
||||
results.append(json.load(response)['inserted'])
|
||||
self.assertEqual(results,[1,0])
|
||||
with contextlib.closing(sqlite3.connect(store)) as db:
|
||||
stored=json.loads(db.execute('SELECT payload FROM events').fetchone()[0])
|
||||
self.assertEqual(stored['message']['body'],'Grüße 🐈')
|
||||
payload['listener_id']='other'
|
||||
request=urllib.request.Request(url,data=json.dumps(payload).encode())
|
||||
from urllib.error import HTTPError
|
||||
with self.assertRaises(HTTPError) as error:
|
||||
urllib.request.urlopen(request,timeout=3)
|
||||
self.assertEqual(error.exception.code,400)
|
||||
finally:
|
||||
server.shutdown();server.server_close();thread.join(3)
|
||||
|
||||
def test_receiver_storage_failure_does_not_acknowledge(self):
|
||||
import threading
|
||||
import urllib.request
|
||||
from urllib.error import HTTPError
|
||||
import sqlite3
|
||||
from whatsapp_suite import webhook
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
server=webhook.make_server(0,Path(folder)/'events.sqlite3')
|
||||
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
|
||||
try:
|
||||
with patch.object(webhook,'store_events',side_effect=sqlite3.OperationalError('fixture')):
|
||||
request=urllib.request.Request('http://127.0.0.1:'+str(server.server_port)+'/whatsapp-webhook',data=b'{}')
|
||||
with self.assertRaises(HTTPError) as error:
|
||||
urllib.request.urlopen(request,timeout=3)
|
||||
self.assertEqual(error.exception.code,503)
|
||||
finally:
|
||||
server.shutdown();server.server_close();thread.join(3)
|
||||
|
||||
def test_restore_loop_installs_missing_listener_without_replacing_another(self):
|
||||
import threading
|
||||
from whatsapp_suite import webhook
|
||||
listener='91b17841-2106-4e73-97db-c38be6e9b269'
|
||||
config={'version':1,'port':18763,'self_only':True,'since_ms':1,'listener_id':listener}
|
||||
stopped=threading.Event();health={}
|
||||
def manage(action,*args):
|
||||
if action=='status':return {'passed':True,'pid':42,'listener':{'installed':False},'relay':{'running':False}}
|
||||
stopped.set();return {'passed':True,'pid':42}
|
||||
with patch.object(webhook,'read_config',return_value=config),patch.object(webhook,'manage',side_effect=manage) as operations:
|
||||
webhook.restore_loop('fixture.json',health,stopped)
|
||||
self.assertEqual([c.args[0] for c in operations.call_args_list],['status','install'])
|
||||
self.assertEqual(health['restores'],1)
|
||||
class OneIteration:
|
||||
def __init__(self):self.done=False
|
||||
def is_set(self):return self.done
|
||||
def wait(self,*args):self.done=True
|
||||
with patch.object(webhook,'read_config',return_value=config),patch.object(webhook,'manage',return_value={
|
||||
'passed':True,'pid':42,'listener':{'installed':True,'listener_id':'other'},'relay':{'running':True}}) as operations:
|
||||
webhook.restore_loop('fixture.json',health,OneIteration())
|
||||
self.assertEqual(operations.call_count,1)
|
||||
self.assertIn('refusing replacement',health['last_error'])
|
||||
|
||||
def test_receiver_configuration_and_native_asset_integrity(self):
|
||||
from whatsapp_suite import webhook
|
||||
config={'version':1,'port':18763,'self_only':True,'since_ms':1,
|
||||
'listener_id':'91b17841-2106-4e73-97db-c38be6e9b269'}
|
||||
self.assertEqual(webhook.validate_config(config),config)
|
||||
with self.assertRaises(ValueError):webhook.validate_config(dict(config,port=70000))
|
||||
self.assertEqual(hashlib.sha256(asset_bytes('webhook_probe.dll')).hexdigest(),webhook.HELPER_SHA256)
|
||||
|
||||
def test_direct_https_configuration_rejects_unsafe_routes(self):
|
||||
from whatsapp_suite import webhook
|
||||
config={'version':1,'self_only':True,'since_ms':1,
|
||||
'listener_id':'91b17841-2106-4e73-97db-c38be6e9b269',
|
||||
'endpoint':'https://receiver.example/webhook','token_env':'FIXTURE_TOKEN'}
|
||||
self.assertEqual(webhook.validate_config(config),config)
|
||||
for endpoint in ('http://receiver.example/webhook','https://user:[email protected]/webhook',
|
||||
'https://receiver.example/webhook?token=unsafe','https://receiver.example/webhook#fragment'):
|
||||
with self.assertRaises(ValueError):webhook.validate_config(dict(config,endpoint=endpoint))
|
||||
|
||||
@unittest.skipUnless(sys.platform=='win32','Windows DPAPI required')
|
||||
def test_windows_token_protection_roundtrip_without_plaintext_in_config(self):
|
||||
from whatsapp_suite import webhook
|
||||
value='synthetic-webhook-token-1234567890'
|
||||
protected=webhook.protect_token(value)
|
||||
self.assertNotIn(value,protected)
|
||||
self.assertEqual(webhook.protect_token(protected,decrypt=True),value)
|
||||
|
||||
def test_one_shot_restore_respects_disabled_configuration(self):
|
||||
from whatsapp_suite import webhook
|
||||
config={'version':1,'port':18763,'self_only':True,'since_ms':1,'enabled':False,
|
||||
'listener_id':'91b17841-2106-4e73-97db-c38be6e9b269'}
|
||||
with patch.object(webhook,'read_config',return_value=config),patch.object(webhook,'manage') as operation:
|
||||
result=webhook.ensure_listener('fixture')
|
||||
self.assertTrue(result['disabled']);operation.assert_not_called()
|
||||
@@ -0,0 +1,34 @@
|
||||
const assert=require('node:assert/strict');
|
||||
const vm=require('node:vm');
|
||||
let source='';process.stdin.setEncoding('utf8');process.stdin.on('data',s=>source+=s);
|
||||
process.stdin.on('end',async()=>{
|
||||
const handlers=new Map();
|
||||
const collection={on(event,fn){handlers.set(event,fn);},off(event,fn){if(handlers.get(event)===fn)handlers.delete(event);},getModelsArray(){return [];}};
|
||||
const rows=new Map(),now=Date.now();
|
||||
const make=(id,remote='self',timestamp=now)=>({id:{toString:()=>id,remote:{toString:()=>remote},fromMe:remote==='self'},t:Math.floor(timestamp/1000)});
|
||||
const modules={WAWebMsgCollection:{MsgCollection:collection},WAWebUserPrefsMeUser:{isMeAccount:r=>r.toString()==='self'},
|
||||
WAWebSchemaMessage:{getMessageTable:()=>({get:async id=>rows.get(id)})},WAWebDBMessageSerialization:{messageFromDbRow:r=>r}};
|
||||
const context={window:{},require:name=>modules[name],Date,setTimeout,clearTimeout,Map,Set,Number};
|
||||
const run=self_only=>vm.runInNewContext(source.replace('__CONFIG__',JSON.stringify({listener_id:'fixture',self_only,since_ms:now,reconcile:false})),context);
|
||||
const wait=()=>new Promise(r=>setTimeout(r,110));
|
||||
run(true);
|
||||
rows.set('one',{body:'Grüße 🐈',caption:null,type:'chat',t:now/1000});
|
||||
handlers.get('add')(make('one'));await wait();
|
||||
const state=context.window.__whatsappWebhookV1;
|
||||
assert.equal(state.queue.length,1);assert.equal(state.queue[0].message.body,'Grüße 🐈');
|
||||
handlers.get('change:body')(make('one'));await wait();assert.equal(state.queue.length,1);
|
||||
handlers.get('add')(make('other','other'));handlers.get('add')(make('old','self',now-60000));await wait();
|
||||
assert.equal(state.queue.length,1);assert.equal(state.status().pending,0);
|
||||
state.stop();assert.equal(handlers.size,0);assert.equal(state.status().installed,false);
|
||||
run(false);
|
||||
rows.set('incoming',{body:'Incoming fixture',type:'chat',t:now/1000});
|
||||
handlers.get('add')(make('incoming','other'));await wait();
|
||||
assert.equal(context.window.__whatsappWebhookV1.queue[0].message.from_me,false);
|
||||
rows.set('long',{body:'x'.repeat(14000),caption:'y'.repeat(5000),type:'chat',t:now/1000});
|
||||
handlers.get('add')(make('long'));await wait();
|
||||
const event=context.window.__whatsappWebhookV1.queue[1];
|
||||
assert.equal(event.message.body.length,12000);assert.equal(event.message.caption.length,4000);
|
||||
assert.equal(event.message.text_truncated,true);assert.equal(event.message.body_length,14000);
|
||||
context.window.__whatsappWebhookV1.stop();
|
||||
console.log('Webhook event filtering, decoding, deduplication, bounds and stop passed.');
|
||||
}).on('error',e=>{throw e;});
|
||||
+1930
-1466
File diff suppressed because it is too large.
Load diff
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,63 @@
|
||||
(() => {
|
||||
const config=__CONFIG__, key='__whatsappWebhookV1';
|
||||
const existing=window[key];
|
||||
if(existing&&!existing.stopped){
|
||||
if(existing.listener_id!==config.listener_id)throw Error('Another listener is installed; stop it first');
|
||||
return existing.status();
|
||||
}
|
||||
const collection=require('WAWebMsgCollection').MsgCollection;
|
||||
const prefs=require('WAWebUserPrefsMeUser');
|
||||
if(typeof collection.on!=='function'||typeof collection.off!=='function')throw Error('Message events unavailable');
|
||||
const seen=new Set(), pending=new Map(), timers=new Set();
|
||||
const state={listener_id:config.listener_id,queue:[],stopped:false,delivered:0,
|
||||
dropped:0,decode_errors:0,last_error:null,observed:0,sequence:0,
|
||||
installed_at:Date.now(),self_only:config.self_only};
|
||||
const identity=m=>m?.id?.toString?.();
|
||||
function later(fn,delay){const timer=setTimeout(()=>{timers.delete(timer);if(!state.stopped)fn();},delay);timers.add(timer);}
|
||||
async function capture(model,attempt=0){
|
||||
const id=identity(model);if(!id||seen.has(id)||state.stopped)return;
|
||||
if(config.self_only&&!prefs.isMeAccount(model.id.remote))return;
|
||||
if(!Number.isFinite(model.t)||model.t*1000<config.since_ms-2000)return;
|
||||
pending.set(id,model);
|
||||
try{
|
||||
const row=await require('WAWebSchemaMessage').getMessageTable().get(id);
|
||||
if(state.stopped||seen.has(id))return;
|
||||
if(!row){if(attempt<8)later(()=>capture(model,attempt+1),250);else{state.decode_errors++;pending.delete(id);}return;}
|
||||
const decoded=require('WAWebDBMessageSerialization').messageFromDbRow(row);
|
||||
if(!decoded)throw Error('Message decoder returned no row');
|
||||
if(state.queue.length>=1000){state.dropped++;pending.delete(id);return;}
|
||||
const event={seq:++state.sequence,event_id:id,type:'message',message:{
|
||||
id,chat_id:model.id.remote.toString(),timestamp:decoded.t??model.t,
|
||||
from_me:!!model.id.fromMe,message_type:decoded.type??model.type??null,
|
||||
body:typeof decoded.body==='string'?decoded.body.slice(0,12000):null,
|
||||
caption:typeof decoded.caption==='string'?decoded.caption.slice(0,4000):null,
|
||||
text_truncated:(decoded.body?.length??0)>12000||(decoded.caption?.length??0)>4000,
|
||||
body_length:typeof decoded.body==='string'?decoded.body.length:0,
|
||||
caption_length:typeof decoded.caption==='string'?decoded.caption.length:0}};
|
||||
state.queue.push(event);seen.add(id);pending.delete(id);
|
||||
if(seen.size>10000)seen.delete(seen.values().next().value);
|
||||
}catch(error){
|
||||
state.last_error=String(error).slice(0,300);
|
||||
if(attempt<8)later(()=>capture(model,attempt+1),250);else{state.decode_errors++;pending.delete(id);}
|
||||
}
|
||||
}
|
||||
function onMessage(model){
|
||||
if(!model?.id||state.stopped)return;
|
||||
state.observed++;
|
||||
if(config.self_only&&!prefs.isMeAccount(model.id.remote))return;
|
||||
if(!Number.isFinite(model.t)||model.t*1000<config.since_ms-2000)return;
|
||||
const id=identity(model);if(!seen.has(id)&&!pending.has(id)){pending.set(id,model);later(()=>capture(model),50);}
|
||||
}
|
||||
const events=['add','change:body','change:caption'];
|
||||
state.status=()=>({installed:!state.stopped,listener_id:state.listener_id,
|
||||
queued:state.queue.length,pending:pending.size,delivered:state.delivered,
|
||||
dropped:state.dropped,decode_errors:state.decode_errors,last_error:state.last_error,
|
||||
observed:state.observed,installed_at:state.installed_at,self_only:state.self_only});
|
||||
state.stop=()=>{state.stopped=true;for(const event of events)collection.off(event,onMessage);
|
||||
for(const timer of timers)clearTimeout(timer);timers.clear();pending.clear();state.queue.length=0;return state.status();};
|
||||
window[key]=state;
|
||||
for(const event of events)collection.on(event,onMessage);
|
||||
// Reconcile recently loaded models on a reinstall; the receiver deduplicates IDs.
|
||||
if(config.reconcile)for(const model of collection.getModelsArray())onMessage(model);
|
||||
return state.status();
|
||||
})()
|
||||
Binary file not shown.
@@ -14,6 +14,7 @@ COMMANDS = {
|
||||
'history-export': ('history', 'Export history through an existing Desktop CDP endpoint'),
|
||||
'export-zip': ('zip_export', 'Export all local messages via JavaScript in WebView; no CDP or UI automation'),
|
||||
'send-self': ('self_message', 'Send one test text to your own WhatsApp account via JavaScript'),
|
||||
'webhook': ('webhook', 'Install/manage an autonomous message listener and local webhook receiver'),
|
||||
'contacts-export': ('contacts_export', 'Export all locally stored contacts via JavaScript without CDP'),
|
||||
'ram-read': ('ram', 'Recover loaded message objects from process RAM'),
|
||||
'ram-search': ('ram_search', 'Search verified loaded message objects'),
|
||||
|
||||
@@ -25,7 +25,7 @@ from .desktop import visible_ui_threads, detect_desktop_pid, TESTED_BUILD
|
||||
WAKE = 0x8000 + 0x498
|
||||
PROTOCOL = 0x4d415432
|
||||
# SHA-256 of assets/math_probe.dll.
|
||||
EMBEDDED_SHA256 = '136620d96f4745a4c41edfe1f4a9272ef25fc060ddace1c0eec1027bf252e02a'
|
||||
EMBEDDED_SHA256 = 'bdcf0449d35102963c35f0f8ba985ffe3e5f83c083da5587b4978c82911c555f'
|
||||
|
||||
CASES = (
|
||||
('(17 * 23) - 19', 372),
|
||||
@@ -355,7 +355,8 @@ def run_math(pid=None, suite='math', _operation=None):
|
||||
'history-read': 'oldest-local-messages-through-javascript-no-cdp',
|
||||
'history-export': 'all-local-message-rows-through-javascript-no-cdp',
|
||||
'send-self': 'one-text-message-to-own-account-through-javascript',
|
||||
'contacts-export': 'all-local-contact-records-through-javascript'}
|
||||
'contacts-export': 'all-local-contact-records-through-javascript',
|
||||
'webhook': 'explicit-javascript-message-listener-management'}
|
||||
report = {'passed': False, 'scope': scopes.get(suite, 'invalid-suite'),
|
||||
'suite': suite, 'frida_used': False,
|
||||
'startup_acknowledged': False, 'javascript_executed': False,
|
||||
@@ -434,7 +435,7 @@ def run_math(pid=None, suite='math', _operation=None):
|
||||
report['javascript_executed'] = True
|
||||
return packet.result
|
||||
|
||||
if suite in ('history-export', 'send-self', 'contacts-export'):
|
||||
if suite in ('history-export', 'send-self', 'contacts-export', 'webhook'):
|
||||
if _operation is None:
|
||||
raise ValueError('Use the export-zip command for history export')
|
||||
_operation(execute, probe_id, report)
|
||||
|
||||
@@ -6,7 +6,7 @@ from .common import BASE, verified_helper
|
||||
from .desktop import detect_desktop_pid, visible_ui_threads
|
||||
|
||||
|
||||
EMBEDDED_PROBE_SHA256 = '911beb9f6bb52390cde8a75562ca1d6c46f4629f641426a5250286903de60895'
|
||||
EMBEDDED_PROBE_SHA256 = 'f65fc87dff2b3b4b81a46c227659e652e091bfec7f15fff5a1c85c3e10d3a9ef'
|
||||
|
||||
|
||||
def build_native_probe():
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
"""Autonomous in-process message listener with HTTPS or loopback delivery.
|
||||
|
||||
The install command exits; a resident native timer relays queued JavaScript
|
||||
events. The optional receiver restore loop reattaches after app/page restarts.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
from contextlib import closing
|
||||
import ctypes
|
||||
from ctypes import wintypes as w
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from .common import BASE, asset_bytes, verified_helper, cli_command
|
||||
from .desktop import detect_desktop_pid, visible_ui_threads
|
||||
from .live import Packet, run_math, windows_api, wait_for_state
|
||||
from .zip_export import evaluate
|
||||
|
||||
HELPER_SHA256 = '10acf825aa3c37f356bff6639534db192a5e9babb0402e5d0651f8aecf8c2101'
|
||||
WAKE = 0x8000 + 0x499
|
||||
PROTOCOL = 0x57484231
|
||||
STATE_KEY = '__whatsappWebhookV1'
|
||||
|
||||
|
||||
def validate_config(config):
|
||||
if not isinstance(config, dict) or config.get('version') != 1:
|
||||
raise ValueError('Unsupported webhook configuration')
|
||||
if config.get('endpoint'):
|
||||
endpoint=urlsplit(config['endpoint'])
|
||||
if (endpoint.scheme!='https' or not endpoint.hostname or endpoint.username or endpoint.password
|
||||
or endpoint.fragment or endpoint.query or not endpoint.path.startswith('/')
|
||||
or '\n' in config['endpoint'] or '\r' in config['endpoint']):
|
||||
raise ValueError('Direct endpoint must be an HTTPS URL without credentials, query or fragment')
|
||||
if not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*',config.get('token_env','')):
|
||||
raise ValueError('token_env must name a credential environment variable')
|
||||
elif type(config.get('port')) is not int or not 1024 <= config['port'] <= 65535:
|
||||
raise ValueError('Loopback receiver port must be between 1024 and 65535')
|
||||
if type(config.get('self_only')) is not bool:
|
||||
raise ValueError('self_only must be a boolean')
|
||||
if type(config.get('enabled', True)) is not bool:
|
||||
raise ValueError('enabled must be a boolean')
|
||||
if type(config.get('since_ms')) is not int or config['since_ms'] < 0:
|
||||
raise ValueError('Invalid listener start time')
|
||||
uuid.UUID(config.get('listener_id', ''))
|
||||
return config
|
||||
|
||||
|
||||
def read_config(path):
|
||||
return validate_config(json.loads(Path(path).read_text(encoding='utf-8')))
|
||||
|
||||
|
||||
def save_config(path, config):
|
||||
"""Replace complete configuration so a restore check never sees partial JSON."""
|
||||
path = Path(path)
|
||||
descriptor, temporary = tempfile.mkstemp(prefix='listener-', suffix='.tmp', dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(descriptor, 'w', encoding='utf-8') as stream:
|
||||
json.dump(config, stream, indent=2)
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
Path(temporary).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def protect_token(value, decrypt=False):
|
||||
"""Windows current-user DPAPI; persisted configuration contains no raw token."""
|
||||
if os.name != 'nt':
|
||||
raise ValueError('Token protection requires Windows DPAPI')
|
||||
class Blob(ctypes.Structure):
|
||||
_fields_ = [('length', w.DWORD), ('data', ctypes.POINTER(ctypes.c_ubyte))]
|
||||
raw = base64.b64decode(value, validate=True) if decrypt else value.encode('ascii')
|
||||
buffer = ctypes.create_string_buffer(raw)
|
||||
source = Blob(len(raw), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte)))
|
||||
output = Blob()
|
||||
crypt = ctypes.WinDLL('crypt32', use_last_error=True)
|
||||
kernel = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
kernel.LocalFree.argtypes = [ctypes.c_void_p]
|
||||
kernel.LocalFree.restype = ctypes.c_void_p
|
||||
if decrypt:
|
||||
call = crypt.CryptUnprotectData
|
||||
call.argtypes = [ctypes.POINTER(Blob), ctypes.c_void_p, ctypes.c_void_p,
|
||||
ctypes.c_void_p, ctypes.c_void_p, w.DWORD, ctypes.POINTER(Blob)]
|
||||
arguments = (ctypes.byref(source), None, None, None, None, 1, ctypes.byref(output))
|
||||
else:
|
||||
call = crypt.CryptProtectData
|
||||
call.argtypes = [ctypes.POINTER(Blob), w.LPCWSTR, ctypes.c_void_p,
|
||||
ctypes.c_void_p, ctypes.c_void_p, w.DWORD, ctypes.POINTER(Blob)]
|
||||
arguments = (ctypes.byref(source), 'WhatsApp injector webhook token', None,
|
||||
None, None, 1, ctypes.byref(output))
|
||||
call.restype = w.BOOL
|
||||
try:
|
||||
if not call(*arguments):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
data = ctypes.string_at(output.data, output.length)
|
||||
return data.decode('ascii') if decrypt else base64.b64encode(data).decode('ascii')
|
||||
finally:
|
||||
ctypes.memset(buffer, 0, len(raw))
|
||||
if output.data:
|
||||
ctypes.memset(output.data, 0, output.length)
|
||||
kernel.LocalFree(output.data)
|
||||
|
||||
|
||||
def configuration_token(config):
|
||||
value = os.environ.get(config.get('token_env', ''))
|
||||
if not value and config.get('protected_token'):
|
||||
value = protect_token(config['protected_token'], decrypt=True)
|
||||
return value
|
||||
|
||||
|
||||
def register_restore_task(config_path):
|
||||
"""One per-user scheduled check, not a resident Python service."""
|
||||
config_path = Path(config_path).resolve()
|
||||
config = read_config(config_path)
|
||||
if not config.get('endpoint') or os.name != 'nt':
|
||||
raise ValueError('Scheduled restoration requires a direct HTTPS endpoint on Windows')
|
||||
token = configuration_token(config)
|
||||
if not token or not re.fullmatch(r'[!-~]{32,128}', token):
|
||||
raise ValueError('A valid webhook token is required for automatic restoration')
|
||||
config['protected_token'] = protect_token(token)
|
||||
name = 'WhatsAppInjector-' + config['listener_id']
|
||||
command = cli_command('webhook', 'ensure', '--config', str(config_path))
|
||||
if command[1] == '-m':
|
||||
bundle = BASE / 'whatsapp.py'
|
||||
if not bundle.exists():
|
||||
raise ValueError('Build whatsapp.py before registering a restore task')
|
||||
command = [sys.executable, str(bundle), 'webhook', 'ensure', '--config', str(config_path)]
|
||||
executable = Path(command[0])
|
||||
windowless = executable.with_name('pythonw.exe')
|
||||
if windowless.exists():
|
||||
executable = windowless
|
||||
arguments = subprocess.list2cmdline(command[1:])
|
||||
quote = lambda value: "'" + str(value).replace("'", "''") + "'"
|
||||
script = '''$ErrorActionPreference='Stop'
|
||||
$taskName=__NAME__
|
||||
$taskAction=New-ScheduledTaskAction -Execute __EXE__ -Argument __ARGS__ -WorkingDirectory __CWD__
|
||||
$taskTrigger=New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval ([TimeSpan]::FromMinutes(1))
|
||||
$taskPrincipal=New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Limited
|
||||
$taskSettings=New-ScheduledTaskSettingsSet -Hidden -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit ([TimeSpan]::FromSeconds(30)) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
||||
$existing=Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
if($existing){
|
||||
if($existing.Actions.Execute -ne $taskAction.Execute -or $existing.Actions.Arguments -ne $taskAction.Arguments){throw 'Task name belongs to another action'}
|
||||
Enable-ScheduledTask -TaskName $taskName | Out-Null
|
||||
}else{
|
||||
Register-ScheduledTask -TaskName $taskName -Action $taskAction -Trigger $taskTrigger -Principal $taskPrincipal -Settings $taskSettings -Description 'Restore explicitly configured WhatsApp message webhook when the app is running' | Out-Null
|
||||
}
|
||||
'''
|
||||
for marker, value in [('__NAME__', name), ('__EXE__', executable),
|
||||
('__ARGS__', arguments), ('__CWD__', config_path.parent)]:
|
||||
script = script.replace(marker, quote(value))
|
||||
# Protected token is saved before the task can run; it is never a task argument.
|
||||
save_config(config_path, config)
|
||||
result = subprocess.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', script],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
if result.returncode:
|
||||
raise RuntimeError('Could not register the per-user restore task: ' + result.stderr[:500])
|
||||
config['restore_task'] = name
|
||||
save_config(config_path, config)
|
||||
return {'task': name, 'interval_seconds': 60, 'token_protection': 'Windows current-user DPAPI'}
|
||||
|
||||
|
||||
def ensure_listener(config_path):
|
||||
config = read_config(config_path)
|
||||
if not config.get('enabled', True):
|
||||
return {'passed': True, 'disabled': True}
|
||||
report = manage('status')
|
||||
if not report['passed']:
|
||||
return {'passed': False, 'error': report.get('error'), 'waiting_for_whatsapp': True}
|
||||
listener, relay = report.get('listener', {}), report.get('relay', {})
|
||||
if listener.get('installed') and listener.get('listener_id') != config['listener_id']:
|
||||
raise ValueError('Another listener is active; refusing replacement')
|
||||
if not listener.get('installed') or not relay.get('running'):
|
||||
report = manage('install', config)
|
||||
report['restored'] = report['passed']
|
||||
return report
|
||||
|
||||
|
||||
def native_control(pid, command, port=0, endpoint=None, token=None):
|
||||
"""Install/status/stop using a short-lived hook; relay holds its own lifetime."""
|
||||
kernel, user = windows_api()
|
||||
threads = visible_ui_threads(pid)
|
||||
if len(threads) != 1:
|
||||
raise ValueError('Expected one WhatsApp UI thread')
|
||||
thread = next(iter(threads))
|
||||
dll, _ = verified_helper('webhook_probe.dll', HELPER_SHA256,
|
||||
'.whatsapp-webhook-native', 'webhook_probe.dll')
|
||||
module = hook = mapping = view = process = None
|
||||
try:
|
||||
process = kernel.OpenProcess(0x100000, False, pid)
|
||||
if not process:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
ctypes.set_last_error(0)
|
||||
mapping = kernel.CreateFileMappingW(w.HANDLE(-1), None, 4, 0,
|
||||
ctypes.sizeof(Packet), 'Local\\WhatsAppWebhook-v1-' + str(pid))
|
||||
existed = ctypes.get_last_error() == 183
|
||||
if not mapping:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
if existed:
|
||||
raise ValueError('Another webhook controller is using this instance')
|
||||
view = kernel.MapViewOfFile(mapping, 0xf001f, 0, 0, ctypes.sizeof(Packet))
|
||||
if not view:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
packet = Packet.from_address(view)
|
||||
packet.thread, packet.protocol = thread, PROTOCOL
|
||||
packet.state = {'install': 0, 'status': 3, 'stop': 6}[command]
|
||||
if command=='install' and endpoint:
|
||||
if not token or not re.fullmatch(r'[!-~]{32,128}',token):
|
||||
raise ValueError('Webhook token must contain 32-128 printable ASCII characters')
|
||||
packet.script=endpoint+'\n'+token
|
||||
else:
|
||||
packet.script = str(port) if command == 'install' else ''
|
||||
module = kernel.LoadLibraryW(str(dll))
|
||||
if not module:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
callback = kernel.GetProcAddress(module, b'ProbeHook')
|
||||
if not callback:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
hook = user.SetWindowsHookExW(3, callback, module, thread)
|
||||
if not hook:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
def wake():
|
||||
if not user.PostThreadMessageW(thread, WAKE, 0, 0):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
def alive():
|
||||
status = kernel.WaitForSingleObject(process, 0)
|
||||
if status == 0xffffffff:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
return status == 258
|
||||
wait_for_state(packet, {'install': 2, 'status': 4, 'stop': 7}[command],
|
||||
10, wake, alive, 'webhook ' + command)
|
||||
return json.loads(packet.result)
|
||||
finally:
|
||||
unhook_error = None
|
||||
if hook and not user.UnhookWindowsHookEx(hook):
|
||||
unhook_error = ctypes.WinError(ctypes.get_last_error())
|
||||
if view:
|
||||
kernel.UnmapViewOfFile(view)
|
||||
for handle in (mapping, process):
|
||||
if handle:
|
||||
kernel.CloseHandle(handle)
|
||||
if module:
|
||||
kernel.FreeLibrary(module)
|
||||
if unhook_error:
|
||||
raise unhook_error
|
||||
|
||||
|
||||
def manage(action, config=None, pid=None):
|
||||
if action == 'install':
|
||||
validate_config(config)
|
||||
def operation(execute, session, report):
|
||||
if action == 'install':
|
||||
source = asset_bytes('webhook_listener.js').decode('utf-8')
|
||||
settings = {key: config[key] for key in ('listener_id', 'self_only', 'since_ms')}
|
||||
settings['reconcile'] = True
|
||||
source = source.replace('__CONFIG__', json.dumps(settings))
|
||||
report['listener'] = evaluate(execute, source, session)
|
||||
try:
|
||||
report['relay'] = native_control(report['pid'], 'install', config.get('port',0),
|
||||
config.get('endpoint'),configuration_token(config) if config.get('endpoint') else None)
|
||||
except Exception:
|
||||
evaluate(execute, 'window.' + STATE_KEY + '?.stop()', session)
|
||||
raise
|
||||
elif action == 'stop':
|
||||
report['relay'] = native_control(report['pid'], 'stop')
|
||||
report['listener'] = evaluate(execute,
|
||||
'window.' + STATE_KEY + '?.stop() ?? {installed:false}', session)
|
||||
else:
|
||||
report['listener'] = evaluate(execute,
|
||||
'window.' + STATE_KEY + '?.status() ?? {installed:false}', session)
|
||||
report['relay'] = native_control(report['pid'], 'status')
|
||||
return run_math(pid, 'webhook', _operation=operation)
|
||||
|
||||
|
||||
def initialize_store(path):
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with closing(sqlite3.connect(path)) as db, db:
|
||||
db.execute('CREATE TABLE IF NOT EXISTS events '
|
||||
'(event_id TEXT PRIMARY KEY, listener_id TEXT NOT NULL, '
|
||||
'received_at REAL NOT NULL, payload TEXT NOT NULL)')
|
||||
|
||||
|
||||
def store_events(path, payload, listener_id=None):
|
||||
"""Commit before HTTP acknowledgement; duplicate deliveries are harmless."""
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get('events'), list):
|
||||
raise ValueError('Expected webhook event batch')
|
||||
if len(payload['events']) > 16:
|
||||
raise ValueError('Too many events')
|
||||
actual = payload.get('listener_id')
|
||||
uuid.UUID(actual or '')
|
||||
if listener_id is not None and actual != listener_id:
|
||||
raise ValueError('Unexpected listener ID')
|
||||
records = []
|
||||
for event in payload['events']:
|
||||
if (not isinstance(event, dict) or event.get('type') != 'message'
|
||||
or type(event.get('seq')) is not int or event['seq'] <= 0
|
||||
or not isinstance(event.get('event_id'), str)
|
||||
or not 1 <= len(event['event_id']) <= 1024
|
||||
or not isinstance(event.get('message'), dict)
|
||||
or event['message'].get('id') != event['event_id']):
|
||||
raise ValueError('Invalid message event')
|
||||
records.append((event['event_id'], actual, time.time(), json.dumps(event, ensure_ascii=False)))
|
||||
with closing(sqlite3.connect(path)) as db, db:
|
||||
before = db.total_changes
|
||||
db.executemany('INSERT OR IGNORE INTO events VALUES (?,?,?,?)', records)
|
||||
return db.total_changes - before
|
||||
|
||||
|
||||
def make_server(port, store, listener_id=None, health=None):
|
||||
initialize_store(store)
|
||||
health = health if health is not None else {}
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
def reply(self, code, value):
|
||||
data = json.dumps(value).encode('utf-8')
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
def do_GET(self):
|
||||
if self.path != '/health':
|
||||
self.reply(404, {'error': 'Not found'})
|
||||
return
|
||||
with closing(sqlite3.connect(store)) as db:
|
||||
count = db.execute('SELECT COUNT(*) FROM events').fetchone()[0]
|
||||
self.reply(200, dict(health, stored_events=count))
|
||||
def do_POST(self):
|
||||
if self.path != '/whatsapp-webhook':
|
||||
self.reply(404, {'error': 'Not found'})
|
||||
return
|
||||
try:
|
||||
length = int(self.headers.get('Content-Length', '0'))
|
||||
if not 0 < length <= 262144:
|
||||
self.reply(413, {'error': 'Invalid payload size'})
|
||||
return
|
||||
self.connection.settimeout(5)
|
||||
data = self.rfile.read(length)
|
||||
if len(data) != length:
|
||||
raise ValueError('Incomplete payload')
|
||||
payload = json.loads(data)
|
||||
inserted = store_events(store, payload, listener_id)
|
||||
self.reply(200, {'accepted': True, 'inserted': inserted})
|
||||
except (ValueError, TypeError, OSError):
|
||||
self.reply(400, {'error': 'Invalid event batch'})
|
||||
except sqlite3.Error:
|
||||
self.reply(503, {'error': 'Storage unavailable; retry'})
|
||||
return ThreadingHTTPServer(('127.0.0.1', port), Handler)
|
||||
|
||||
|
||||
def restore_loop(config_path, health, stopped):
|
||||
"""Receiver-owned bootstrap, needed when process memory is recreated."""
|
||||
while not stopped.is_set():
|
||||
try:
|
||||
config = read_config(config_path)
|
||||
if not config.get('enabled', True):
|
||||
health.update(connected=False, disabled=True, last_error=None)
|
||||
stopped.wait(5)
|
||||
continue
|
||||
health['disabled'] = False
|
||||
report = manage('status')
|
||||
listener, relay = report.get('listener', {}), report.get('relay', {})
|
||||
if not report['passed']:
|
||||
raise RuntimeError(report.get('error', 'Status failed'))
|
||||
if listener.get('installed') and listener.get('listener_id') != config['listener_id']:
|
||||
raise RuntimeError('A different listener is active; refusing replacement')
|
||||
if not listener.get('installed') or not relay.get('running'):
|
||||
if not read_config(config_path).get('enabled', True):
|
||||
continue
|
||||
report = manage('install', config)
|
||||
if not report['passed']:
|
||||
raise RuntimeError(report.get('error', 'Listener restore failed'))
|
||||
health['restores'] = health.get('restores', 0) + 1
|
||||
health.update(connected=True, pid=report['pid'], last_error=None)
|
||||
except (OSError, ValueError, RuntimeError) as error:
|
||||
health.update(connected=False, last_error=str(error)[:500])
|
||||
stopped.wait(5)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest='action', required=True)
|
||||
install = commands.add_parser('install', help='Install listener and resident relay; this process exits')
|
||||
install.add_argument('--port', type=int, default=18763)
|
||||
install.add_argument('--endpoint',help='Direct HTTPS webhook; no local receiver needed')
|
||||
install.add_argument('--token-env',default='WHATSAPP_INJECTOR_WEBHOOK_TOKEN',help='Environment variable holding direct-endpoint token')
|
||||
install.add_argument('--self-only', action='store_true', help='Capture only your self-chat')
|
||||
install.add_argument('--config', type=Path, default=Path('analysis/webhooks/listener.json'))
|
||||
install.add_argument('--pid', type=int)
|
||||
install.add_argument('--restore', action='store_true', help='Reuse the saved configuration')
|
||||
install.add_argument('--auto-restore', action='store_true', help='Register a per-user minute check; requires a direct HTTPS endpoint')
|
||||
for action in ('ensure', 'auto-restore'):
|
||||
sub = commands.add_parser(action)
|
||||
sub.add_argument('--config', type=Path, required=True)
|
||||
for action in ('status', 'stop'):
|
||||
sub = commands.add_parser(action)
|
||||
sub.add_argument('--pid', type=int)
|
||||
if action == 'stop':
|
||||
sub.add_argument('--config', type=Path,
|
||||
help='Disable automatic restore in this saved configuration')
|
||||
watch = commands.add_parser('watch',help='Optional automatic restoration after WhatsApp restarts; keeps this process running')
|
||||
watch.add_argument('--config',type=Path,required=True)
|
||||
serve = commands.add_parser('serve', help='Run loopback receiver, optionally restoring after WhatsApp restarts')
|
||||
serve.add_argument('--port', type=int, default=18763)
|
||||
serve.add_argument('--store', type=Path, default=Path('analysis/webhooks/events.sqlite3'))
|
||||
serve.add_argument('--restore-config', type=Path)
|
||||
args = parser.parse_args(argv)
|
||||
if args.action == 'auto-restore':
|
||||
print(json.dumps(register_restore_task(args.config)))
|
||||
return 0
|
||||
if args.action == 'ensure':
|
||||
report = ensure_listener(args.config)
|
||||
summary = {k: report.get(k) for k in ('passed', 'pid', 'restored', 'disabled', 'waiting_for_whatsapp', 'error')}
|
||||
summary['checked_at'] = time.time()
|
||||
Path(args.config).with_suffix('.status.json').write_text(json.dumps(summary, indent=2), encoding='utf-8')
|
||||
print(json.dumps({k: report.get(k) for k in ('passed', 'pid', 'restored', 'disabled', 'error')}))
|
||||
return 0 if report['passed'] or report.get('waiting_for_whatsapp') else 1
|
||||
if args.action == 'watch':
|
||||
read_config(args.config)
|
||||
stopped=threading.Event();health={}
|
||||
print('Watching WhatsApp restarts; Ctrl+C stops automatic restoration.',flush=True)
|
||||
try:
|
||||
restore_loop(args.config,health,stopped)
|
||||
except KeyboardInterrupt:
|
||||
stopped.set()
|
||||
return 0
|
||||
if args.action == 'serve':
|
||||
config = read_config(args.restore_config) if args.restore_config else None
|
||||
if config and config.get('endpoint'):
|
||||
raise ValueError('Direct endpoints do not use serve; use webhook watch for optional restoration')
|
||||
port = config['port'] if config else args.port
|
||||
if not 1024 <= port <= 65535:
|
||||
raise ValueError('Invalid loopback port')
|
||||
health, stopped = {}, threading.Event()
|
||||
server = make_server(port, args.store, config['listener_id'] if config else None, health)
|
||||
if config:
|
||||
threading.Thread(target=restore_loop, args=(args.restore_config, health, stopped), daemon=True).start()
|
||||
print(json.dumps({'listening': 'http://127.0.0.1:' + str(port), 'store': str(args.store),
|
||||
'automatic_restore': bool(config)}), flush=True)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
stopped.set()
|
||||
server.server_close()
|
||||
return 0
|
||||
config = None
|
||||
if args.action == 'install':
|
||||
if args.restore:
|
||||
config = read_config(args.config)
|
||||
config['enabled'] = True
|
||||
else:
|
||||
if args.config.exists():
|
||||
raise ValueError('Configuration exists; use --restore or a new --config')
|
||||
config = validate_config({'version': 1, 'port': args.port, 'self_only': args.self_only, 'enabled': True,
|
||||
'since_ms': int(time.time() * 1000), 'listener_id': str(uuid.uuid4()),
|
||||
**({'endpoint':args.endpoint,'token_env':args.token_env} if args.endpoint else {})})
|
||||
if args.action == 'stop' and args.config:
|
||||
saved = read_config(args.config)
|
||||
saved['enabled'] = False
|
||||
save_config(args.config, saved)
|
||||
report = manage(args.action, config, args.pid)
|
||||
if args.action == 'install' and report['passed']:
|
||||
args.config.parent.mkdir(parents=True, exist_ok=True)
|
||||
with args.config.open('w' if args.restore else 'x', encoding='utf-8') as stream:
|
||||
json.dump(config, stream, indent=2)
|
||||
if args.auto_restore:
|
||||
report['automatic_restore'] = register_restore_task(args.config)
|
||||
print(json.dumps({k: report.get(k) for k in ('passed', 'pid', 'listener', 'relay', 'automatic_restore', 'error')}))
|
||||
return 0 if report['passed'] else 1
|
||||
Reference in new issue
Block a user