Add autonomous webhooks and dynamic WebView discovery

This commit is contained in:
Ops Agent committed 2026-10-11 08:43:39 +02:00
1 parent ab69f287b6
commit 97f874d183
29 files changed
+3044 -1489

No files matched your search

Binary file not shown.
Binary file not shown.
+63
View File
@@ -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.
+1
View File
@@ -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'),
+4 -3
View File
@@ -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)
+1 -1
View File
@@ -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():
+481
View File
@@ -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