"""Export local WhatsApp Desktop history through its own database decryption. Requires an already-exposed Desktop WebView2 CDP endpoint. Does not enable CDP, restart Desktop, use a separate browser, change messages, or request history sync. """ import argparse import json from pathlib import Path import subprocess import urllib.parse import urllib.request from .analyzer import endpoint PROBE = r'''(async () => { const options = __OPTIONS__; if (location.origin !== 'https://web.whatsapp.com') throw Error('Unexpected origin'); const table = require('WAWebSchemaMessage').getMessageTable(); const serializer = require('WAWebDBMessageSerialization'); const names = await indexedDB.databases(); if (!names.some(d => d.name === 'model-storage')) throw Error('Database absent'); const db = await new Promise((resolve,reject) => { const r=indexedDB.open('model-storage'); r.onupgradeneeded=()=>{r.transaction.abort();reject(Error('Refusing database creation or upgrade'));}; r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(Error('Database open failed')); r.onblocked=()=>reject(Error('Database blocked')); }); const request=r=>new Promise((resolve,reject)=>{r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(Error('Database read failed'));}); try { if (!db.objectStoreNames.contains('message')) throw Error('Message store absent'); if (options.mode==='bounds') { const store=db.transaction('message','readonly').objectStore('message'); const [count,last]=await Promise.all([request(store.count()),request(store.openKeyCursor(null,'prev'))]); const upper=last?.primaryKey??null; if(upper!==null&&typeof upper!=='string') throw Error('Unsupported primary key type'); return {count,upper}; } const keys=await new Promise((resolve,reject)=>{ const tx=db.transaction('message','readonly'),store=tx.objectStore('message'),values=[]; const range=options.after===null?IDBKeyRange.upperBound(options.upper):IDBKeyRange.bound(options.after,options.upper,true,false); const r=store.openKeyCursor(range); r.onerror=()=>reject(Error('Cursor read failed'));tx.onabort=()=>reject(Error('Cursor transaction aborted')); r.onsuccess=()=>{const c=r.result;if(!c||values.length===options.batch){resolve(values);return;} if(typeof c.primaryKey!=='string'){reject(Error('Unsupported primary key type'));return;} values.push(c.primaryKey);c.continue();}; }); const messages=[]; const asString=v=>v==null?null:typeof v==='string'?v:v.toString(); for(const key of keys){ try { // Default get(..., true) follows WAWebIdb.asyncView -> decryptDbMaterial. const row=await table.get(key); if(!row){messages.push({id:key,error:'row_missing_during_live_scan'});continue;} if(row.msgRowOpaqueData&&typeof row.msgRowOpaqueData==='object'&&'_data' in row.msgRowOpaqueData) throw Error('Opaque payload still encrypted'); const m=serializer.messageFromDbRow(row),id=m.id; const out={id:key,message_id:id?.id??null,chat_id:asString(id?.remote),from_me:id?.fromMe??null, type:m.type??null,timestamp:m.t??null,body_length:typeof m.body==='string'?m.body.length:null, caption_length:typeof m.caption==='string'?m.caption.length:null}; if(!options.redact){out.body=typeof m.body==='string'?m.body:null;out.caption=typeof m.caption==='string'?m.caption:null;} messages.push(out); }catch(e){messages.push({id:key,error:'row_decryption_or_decoding_failed',error_type:e?.name??'Error'});} } return {keys,messages,last:keys.length?keys[keys.length-1]:options.after}; }finally{db.close();} })()''' def desktop_ports(): # Read process metadata only; never start a process with debugging flags. script = r'''$p=@(Get-CimInstance Win32_Process -Filter "Name='WhatsApp.Root.exe' OR Name='msedgewebview2.exe'");$ids=@($p | Where-Object Name -eq 'WhatsApp.Root.exe' | ForEach-Object ProcessId);for($i=0;$i -lt 8;$i++){$ids+=@($p | Where-Object {$_.ParentProcessId -in $ids} | ForEach-Object ProcessId);$ids=@($ids | Select-Object -Unique)};$r=@($p | Where-Object {$_.ProcessId -in $ids -and $_.CommandLine -match '--remote-debugging-port=(\d+)'} | ForEach-Object {[int]$Matches[1]});ConvertTo-Json -InputObject $r -Compress''' result=subprocess.run(['powershell.exe','-NoProfile','-NonInteractive','-Command',script],capture_output=True,text=True,check=True) return set(json.loads(result.stdout or '[]')) def evaluate(ws, expression, ident): ws.send(json.dumps({'id':ident,'method':'Runtime.evaluate','params':{'expression':expression,'returnByValue':True,'awaitPromise':True}})) while True: response=json.loads(ws.recv(timeout=120)) if response.get('id')!=ident:continue if response.get('error') or response.get('result',{}).get('exceptionDetails'): raise RuntimeError('WhatsApp database query failed; session may be locked or modules unavailable') return response['result']['result']['value'] def expression(options): return PROBE.replace('__OPTIONS__',json.dumps(options)) def export(cdp, output, batch=100, redact=False): output=Path(output) if output.exists() or output.with_suffix(output.suffix+'.partial').exists(): raise RuntimeError('Output already exists; choose a new --output path') return _export(cdp,output,batch,redact) def _export(cdp, output, batch=100, redact=False): from websockets.sync.client import connect ports=desktop_ports() if not ports: raise RuntimeError('Running WhatsApp Desktop exposes no CDP interface. ReadProcessMemory cannot execute its database decryption methods; Desktop was not restarted or modified.') if cdp is None: if len(ports)!=1:raise RuntimeError('Multiple Desktop CDP ports; supply --cdp') cdp='http://127.0.0.1:'+str(next(iter(ports))) parsed=urllib.parse.urlsplit(cdp) if (parsed.port or (443 if parsed.scheme=='https' else 80)) not in ports: raise RuntimeError('Endpoint does not belong to a detected WhatsApp Desktop debugging port') with urllib.request.urlopen(cdp+'/json/list',timeout=10) as response:targets=json.load(response) pages=[t for t in targets if t.get('type')=='page' and urllib.parse.urlsplit(t.get('url','')).hostname=='web.whatsapp.com'] if len(pages)!=1:raise RuntimeError('Expected one WhatsApp Desktop page target') url=pages[0]['webSocketDebuggerUrl'] if urllib.parse.urlsplit(url).hostname not in ('127.0.0.1','::1'):raise RuntimeError('Non-loopback WebSocket refused') output=Path(output) partial=output.with_suffix(output.suffix+'.partial') report_path=output.with_suffix(output.suffix+'.report.json') if output.exists() or partial.exists():raise RuntimeError('Output already exists; choose a new --output path') output.parent.mkdir(parents=True,exist_ok=True) report={'status':'running','rows':0,'row_errors':0,'snapshot_atomic':False, 'scope':'All encountered local message rows up to initial upper primary key; no phone sync', 'rea_evidence':'ev_8be2c4d68d5e268d7782a8098715202eaeda83625aa5b7f25277b09929f6995e'} try: with connect(url,max_size=16_000_000,open_timeout=10) as ws: bounds=evaluate(ws,expression({'mode':'bounds'}),1) report['initial_row_count']=bounds['count'] upper,after,ident=bounds['upper'],None,2 with partial.open('x',encoding='utf-8') as stream: while upper is not None and after!=upper: result=evaluate(ws,expression({'mode':'batch','upper':upper,'after':after,'batch':batch,'redact':redact}),ident) ident+=1 if not result['keys']:break if result['last']==after:raise RuntimeError('Cursor made no progress') if len(result['messages'])!=len(result['keys']):raise RuntimeError('Incomplete batch response') for message in result['messages']: stream.write(json.dumps(message,ensure_ascii=False)+'\n') report['rows']+=1 report['row_errors']+=int('error' in message) stream.flush();after=result['last'] print(json.dumps({'exported_rows':report['rows'],'row_errors':report['row_errors']}),flush=True) report['final_row_count']=evaluate(ws,expression({'mode':'bounds'}),ident)['count'] report['status']='completed_with_row_errors' if report['row_errors'] else 'completed_live_local_scan' partial.rename(output) report['output']=str(output.resolve()) except Exception: report['status']='failed_partial_export' raise finally: report_path.write_text(json.dumps(report,indent=2),encoding='utf-8') return report def main(argv=None): p=argparse.ArgumentParser(description=__doc__) p.add_argument('--cdp',type=endpoint) p.add_argument('--batch-size',type=int,default=100) p.add_argument('--redact-text',action='store_true') p.add_argument('--output',type=Path,default=Path('whatsapp-local-history.jsonl')) args=p.parse_args(argv) if not 1<=args.batch_size<=500:p.error('--batch-size must be 1..500') try:report=export(args.cdp,args.output,args.batch_size,args.redact_text) except (RuntimeError,OSError) as error:print(str(error));return 2 print(json.dumps(report));return 0 if not report['row_errors'] else 1 if __name__=='__main__':raise SystemExit(main())