"""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