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