| 1 | /** |
| 2 | * WebSocket server for Python-Node.js bridge communication. |
| 3 | * Security: binds to 127.0.0.1 only; requires BRIDGE_TOKEN auth; rejects browser Origin headers. |
| 4 | */ |
| 5 | |
| 6 | import { WebSocketServer, WebSocket } from 'ws'; |
| 7 | import { WhatsAppClient, InboundMessage } from './whatsapp.js'; |
| 8 | |
| 9 | interface SendCommand { |
| 10 | type: 'send'; |
| 11 | to: string; |
| 12 | text: string; |
| 13 | } |
| 14 | |
| 15 | interface SendMediaCommand { |
| 16 | type: 'send_media'; |
| 17 | to: string; |
| 18 | filePath: string; |
| 19 | mimetype: string; |
| 20 | caption?: string; |
| 21 | fileName?: string; |
| 22 | } |
| 23 | |
| 24 | type BridgeCommand = SendCommand | SendMediaCommand; |
| 25 | |
| 26 | interface BridgeMessage { |
| 27 | type: 'message' | 'status' | 'qr' | 'error'; |
| 28 | [key: string]: unknown; |
| 29 | } |
| 30 | |
| 31 | export class BridgeServer { |
| 32 | private wss: WebSocketServer | null = null; |
| 33 | private wa: WhatsAppClient | null = null; |
| 34 | private clients: Set<WebSocket> = new Set(); |
| 35 | |
| 36 | constructor(private port: number, private authDir: string, private token: string) {} |
| 37 | |
| 38 | async start(): Promise<void> { |
| 39 | if (!this.token.trim()) { |
| 40 | throw new Error('BRIDGE_TOKEN is required'); |
| 41 | } |
| 42 | |
| 43 | // Bind to localhost only — never expose to external network |
| 44 | this.wss = new WebSocketServer({ |
| 45 | host: '127.0.0.1', |
| 46 | port: this.port, |
| 47 | verifyClient: (info, done) => { |
| 48 | const origin = info.origin || info.req.headers.origin; |
| 49 | if (origin) { |
| 50 | console.warn(`Rejected WebSocket connection with Origin header: ${origin}`); |
| 51 | done(false, 403, 'Browser-originated WebSocket connections are not allowed'); |
| 52 | return; |
| 53 | } |
| 54 | done(true); |
| 55 | }, |
| 56 | }); |
| 57 | console.log(`🌉 Bridge server listening on ws://127.0.0.1:${this.port}`); |
| 58 | console.log('🔒 Token authentication enabled'); |
| 59 | |
| 60 | // Initialize WhatsApp client |
| 61 | this.wa = new WhatsAppClient({ |
| 62 | authDir: this.authDir, |
| 63 | onMessage: (msg) => this.broadcast({ type: 'message', ...msg }), |
| 64 | onQR: (qr) => this.broadcast({ type: 'qr', qr }), |
| 65 | onStatus: (status) => this.broadcast({ type: 'status', status }), |
| 66 | }); |
| 67 | |
| 68 | // Handle WebSocket connections |
| 69 | this.wss.on('connection', (ws) => { |
| 70 | // Require auth handshake as first message |
| 71 | const timeout = setTimeout(() => ws.close(4001, 'Auth timeout'), 5000); |
| 72 | ws.once('message', (data) => { |
| 73 | clearTimeout(timeout); |
| 74 | try { |
| 75 | const msg = JSON.parse(data.toString()); |
| 76 | if (msg.type === 'auth' && msg.token === this.token) { |
| 77 | console.log('🔗 Python client authenticated'); |
| 78 | this.setupClient(ws); |
| 79 | } else { |
| 80 | ws.close(4003, 'Invalid token'); |
| 81 | } |
| 82 | } catch { |
| 83 | ws.close(4003, 'Invalid auth message'); |
| 84 | } |
| 85 | }); |
| 86 | }); |
| 87 | |
| 88 | // Connect to WhatsApp |
| 89 | await this.wa.connect(); |
| 90 | } |
| 91 | |
| 92 | private setupClient(ws: WebSocket): void { |
| 93 | this.clients.add(ws); |
| 94 | |
| 95 | ws.on('message', async (data) => { |
| 96 | try { |
| 97 | const cmd = JSON.parse(data.toString()) as BridgeCommand; |
| 98 | await this.handleCommand(cmd); |
| 99 | ws.send(JSON.stringify({ type: 'sent', to: cmd.to })); |
| 100 | } catch (error) { |
| 101 | console.error('Error handling command:', error); |
| 102 | ws.send(JSON.stringify({ type: 'error', error: String(error) })); |
| 103 | } |
| 104 | }); |
| 105 | |
| 106 | ws.on('close', () => { |
| 107 | console.log('🔌 Python client disconnected'); |
| 108 | this.clients.delete(ws); |
| 109 | }); |
| 110 | |
| 111 | ws.on('error', (error) => { |
| 112 | console.error('WebSocket error:', error); |
| 113 | this.clients.delete(ws); |
| 114 | }); |
| 115 | } |
| 116 | |
| 117 | private async handleCommand(cmd: BridgeCommand): Promise<void> { |
| 118 | if (!this.wa) return; |
| 119 | |
| 120 | if (cmd.type === 'send') { |
| 121 | await this.wa.sendMessage(cmd.to, cmd.text); |
| 122 | } else if (cmd.type === 'send_media') { |
| 123 | await this.wa.sendMedia(cmd.to, cmd.filePath, cmd.mimetype, cmd.caption, cmd.fileName); |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | private broadcast(msg: BridgeMessage): void { |
| 128 | const data = JSON.stringify(msg); |
| 129 | for (const client of this.clients) { |
| 130 | if (client.readyState === WebSocket.OPEN) { |
| 131 | client.send(data); |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | async stop(): Promise<void> { |
| 137 | // Close all client connections |
| 138 | for (const client of this.clients) { |
| 139 | client.close(); |
| 140 | } |
| 141 | this.clients.clear(); |
| 142 | |
| 143 | // Close WebSocket server |
| 144 | if (this.wss) { |
| 145 | this.wss.close(); |
| 146 | this.wss = null; |
| 147 | } |
| 148 | |
| 149 | // Disconnect WhatsApp |
| 150 | if (this.wa) { |
| 151 | await this.wa.disconnect(); |
| 152 | this.wa = null; |
| 153 | } |
| 154 | } |
| 155 | } |
| 156 |