Files

481 lines
24 KiB
Python
Raw Permalink Normal View History

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