const express = require('express'); const { remote } = require('webdriverio'); const multer = require('multer'); const { execSync, exec } = require('child_process'); const path = require('path'); const fs = require('fs'); const APPIUM_HOST = process.env.APPIUM_HOST || '127.0.0.1'; const APPIUM_PORT = parseInt(process.env.APPIUM_PORT || '4723', 10); const PORT = parseInt(process.env.PORT || '3010', 10); const WEBHOOK_SECRET = process.env.WEBHOOK_SHARED_SECRET || 'dev-secret-change-me'; const MEDIA_WAIT_MS = parseInt(process.env.MEDIA_WAIT_MS || '8000', 10); const WHATSAPP_IMAGES_DIR = '/sdcard/Android/media/com.whatsapp/WhatsApp/Media/WhatsApp Images'; const upload = multer({ dest: '/tmp/wa-uploads/', limits: { fileSize: 20 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { if (!file.mimetype.startsWith('image/')) { return cb(new Error('Only image files are supported')); } cb(null, true); }, }); const app = express(); app.use(express.json({ limit: '10mb' })); let _driver = null; async function getDriver() { if (_driver) { try { await _driver.getSession(); return _driver; } catch { try { await _driver.deleteSession(); } catch {} _driver = null; } } _driver = await remote({ hostname: APPIUM_HOST, port: APPIUM_PORT, capabilities: { platformName: 'Android', 'appium:automationName': 'UiAutomator2', 'appium:deviceName': 'Android', 'appium:noReset': true, 'appium:newCommandTimeout': 120, }, logLevel: 'warn', }); return _driver; } async function retry(fn, attempts = 3, delay = 2000) { for (let i = 0; i < attempts; i++) { try { return await fn(); } catch (e) { if (i === attempts - 1) throw e; await new Promise((r) => setTimeout(r, delay)); } } } function uia(selector) { return `android=new UiSelector().${selector}`; } function find(d, selector, timeout = 5000) { return d.$(selector).waitForExist({ timeout }).then(() => d.$(selector), () => null); } function adb(args) { return execSync(`adb ${args}`, { timeout: 30000, encoding: 'utf8' }).trim(); } async function ensureWhatsApp(d) { const pkg = await d.getCurrentPackage(); if (pkg !== 'com.whatsapp') { await d.startActivity('com.whatsapp', 'com.whatsapp.HomeActivity'); await d.pause(2000); } } async function goHome(d) { await retry(async () => { await d.startActivity('com.whatsapp', 'com.whatsapp.HomeActivity'); await d.pause(1500); }, 2, 1000); } async function openChat(d, query) { await goHome(d); const searchBar = await find( d, uia('resourceId("com.whatsapp:id/search_bar_inner_layout")'), 3000 ); if (!searchBar) throw new Error('Could not find search bar'); await searchBar.click(); await d.pause(2000); const searchInput = await find(d, uia('resourceId("com.whatsapp:id/search_input")'), 3000); if (!searchInput) throw new Error('Could not find search input'); await searchInput.clearValue(); await d.pause(300); await searchInput.setValue(query); await d.pause(2000); const chats = await d.$$(uia('resourceId("com.whatsapp:id/contact_row_container")')); for (const row of chats) { const nameEl = await row.$( uia('resourceId("com.whatsapp:id/conversations_row_contact_name")') ); const name = await nameEl.getText().catch(() => ''); if (name.toLowerCase().includes(query.toLowerCase())) { await row.click(); await d.pause(1500); return; } } if (chats.length > 0) { await chats[0].click(); await d.pause(1500); return; } throw new Error(`No chat found for "${query}"`); } // ── ADB helpers ──────────────────────────────────────────────── function adbPush(localPath, remotePath) { return adb(`push "${localPath}" "${remotePath}"`); } function adbRm(remotePath) { return adb(`shell rm -f "${remotePath}"`); } function adbShell(cmd) { return adb(`shell ${cmd}`); } function adbLatestFile(dir) { const out = adb(`shell ls -t "${dir}/" 2>/dev/null | head -1`); return out ? `${dir}/${out}` : null; } function adbPull(remotePath, localPath) { return adb(`pull "${remotePath}" "${localPath}"`); } // ── Media send ───────────────────────────────────────────────── async function sendMediaViaShareIntent(d, chatName, localPath, mimetype, caption) { const ext = path.extname(localPath) || '.jpg'; const remoteName = `wa_${Date.now()}${ext}`; const remotePath = `/sdcard/Pictures/${remoteName}`; adbPush(localPath, remotePath); try { adbShell( `am start -a android.intent.action.SEND ` + `-t ${mimetype} ` + `--eu android.intent.extra.STREAM file://${remotePath} ` + `-p com.whatsapp` ); await d.pause(3000); const contacts = await d.$$( uia('resourceId("com.whatsapp:id/contactpicker_row_name")') ); let clicked = false; for (const c of contacts) { const name = await c.getText().catch(() => ''); if (name.toLowerCase().includes(chatName.toLowerCase())) { await c.click(); clicked = true; break; } } if (!clicked && contacts.length > 0) { await contacts[0].click(); clicked = true; } if (!clicked) throw new Error(`Contact "${chatName}" not found in share picker`); await d.pause(3000); if (caption) { const captionInput = await find( d, uia('resourceId("com.whatsapp:id/caption")'), 3000 ); if (captionInput) { await captionInput.clearValue(); await d.pause(200); await captionInput.setValue(caption); await d.pause(500); } else { const entry = await find(d, uia('resourceId("com.whatsapp:id/entry")'), 2000); if (entry) { await entry.setValue(caption); await d.pause(500); } } } let sendBtn = await find(d, uia('resourceId("com.whatsapp:id/send")'), 5000); if (!sendBtn) { sendBtn = await find(d, uia('descriptionContains("Send")'), 3000); } if (!sendBtn) throw new Error('Could not find send button after selecting contact'); await sendBtn.click(); await d.pause(2000); goHome(d).catch(() => {}); } finally { adbRm(remotePath); } } // ── Media receive ─────────────────────────────────────────────── function waitForNewImage(beforeTimestamp) { const t0 = beforeTimestamp || Date.now(); const maxWait = MEDIA_WAIT_MS; const pollInterval = 1000; let waited = 0; while (waited < maxWait) { const latest = adbLatestFile(WHATSAPP_IMAGES_DIR); if (latest) { const tsMatch = adbShell(`stat -c '%Y' "${latest}" 2>/dev/null || echo 0`); const ts = parseInt(tsMatch, 10) * 1000; if (ts >= t0) return latest; } new (require('events').EventEmitter)().setMaxListeners(100); const start = Date.now(); while (Date.now() - start < pollInterval) {} waited += pollInterval; } return null; } function pullImageFile(remotePath) { const filename = path.basename(remotePath); const localPath = `/tmp/wa-downloads/${filename}`; fs.mkdirSync('/tmp/wa-downloads', { recursive: true }); adbPull(remotePath, localPath); return localPath; } let mediaTokenCounter = 0; const mediaDownloads = new Map(); function fanoutMedia(event, localPath) { const token = `media_${Date.now()}_${++mediaTokenCounter}`; const filename = path.basename(localPath); mediaDownloads.set(token, { path: localPath, filename, createdAt: Date.now() }); const downloadUrl = `http://127.0.0.1:${PORT}/download/${token}`; const eventWithUrl = { ...event, mediaUrl: downloadUrl, mediaFilename: filename }; for (const sub of subscribers) { fetch(sub.url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(eventWithUrl), }).then((r) => { console.log(` media fanout to ${sub.url}: ${r.status}`); }).catch((e) => { console.log(` media fanout to ${sub.url} failed: ${e.message}`); }); } } // Cleanup old downloads every 5 min setInterval(() => { const cutoff = Date.now() - 10 * 60 * 1000; for (const [token, entry] of mediaDownloads) { if (entry.createdAt < cutoff) { try { fs.unlinkSync(entry.path); } catch {} mediaDownloads.delete(token); } } }, 5 * 60 * 1000); // ── Endpoints (Appium) ───────────────────────────────────────── app.get('/health', async (_req, res) => { try { const d = await getDriver(); await d.getSession(); res.json({ status: 'ok', sessionId: d.sessionId }); } catch (e) { res.status(503).json({ status: 'error', message: e.message }); } }); app.get('/client/getChats/:session', async (req, res) => { try { const d = await getDriver(); await ensureWhatsApp(d); await goHome(d); const rows = await d.$$(uia('resourceId("com.whatsapp:id/contact_row_container")')); const chats = []; for (const row of rows) { try { const nameEl = await row.$( uia('resourceId("com.whatsapp:id/conversations_row_contact_name")') ); const name = await nameEl.getText(); const msgEl = await row.$(uia('resourceId("com.whatsapp:id/single_msg_tv")')); let lastMessage = ''; if (await msgEl.isExisting()) { lastMessage = await msgEl.getText(); } else { const msgFromEl = await row.$(uia('resourceId("com.whatsapp:id/msg_from_tv")')); if (await msgFromEl.isExisting()) lastMessage = await msgFromEl.getText(); } const timeEl = await row.$( uia('resourceId("com.whatsapp:id/conversations_row_date")') ); const timestamp = (await timeEl.isExisting()) ? await timeEl.getText() : ''; const unreadEl = await row.$( uia('resourceId("com.whatsapp:id/conversations_row_unread_indicator")') ); let unread = 0; if (await unreadEl.isExisting()) { const desc = await unreadEl.getAttribute('content-desc'); const m = desc ? desc.match(/(\d+)/) : null; if (m) unread = parseInt(m[1], 10); } chats.push({ name, lastMessage, timestamp, unread }); } catch {} } res.json({ chats }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/chat/fetchMessages/:session', async (req, res) => { try { const { chatId } = req.body; if (!chatId) return res.status(400).json({ error: 'chatId required' }); const d = await getDriver(); await ensureWhatsApp(d); const query = chatId.replace(/@[cg]\.us$/, ''); await openChat(d, query); await d.pause(2000); const layoutEls = await d.$$(uia('resourceId("com.whatsapp:id/main_layout")')); const messages = []; for (const layout of layoutEls) { try { const textEl = await layout.$(uia('resourceId("com.whatsapp:id/message_text")')); const dateEl = await layout.$(uia('resourceId("com.whatsapp:id/date")')); if (await textEl.isExisting()) { messages.push({ text: await textEl.getText(), timestamp: (await dateEl.isExisting()) ? await dateEl.getText() : '', }); } } catch {} } messages.reverse(); goHome(d).catch(() => {}); res.json({ messages }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/client/sendMessage/:session', async (req, res) => { try { const { chatId, content } = req.body; if (!chatId) return res.status(400).json({ error: 'chatId required' }); if (!content) return res.status(400).json({ error: 'content required' }); const d = await getDriver(); await ensureWhatsApp(d); const query = chatId.replace(/@[cg]\.us$/, ''); await openChat(d, query); const input = await find(d, uia('resourceId("com.whatsapp:id/entry")'), 5000); if (!input) throw new Error('Could not find message input'); await input.setValue(content); await d.pause(500); let sendBtn = await find(d, uia('resourceId("com.whatsapp:id/send")'), 3000); if (!sendBtn) { sendBtn = await find(d, uia('descriptionContains("Send")'), 2000); } if (!sendBtn) throw new Error('Could not find send button'); await sendBtn.click(); await d.pause(1000); goHome(d).catch(() => {}); res.json({ status: 'sent', content }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ── Media send endpoint ──────────────────────────────────────── app.post('/messages/send-media', upload.single('image'), async (req, res) => { let localPath = null; try { const { chatName, caption } = req.body; const file = req.file; if (!chatName) return res.status(400).json({ error: 'chatName required' }); if (!file) return res.status(400).json({ error: 'image file required' }); localPath = file.path; const d = await getDriver(); await sendMediaViaShareIntent(d, chatName, localPath, file.mimetype, caption || ''); res.json({ status: 'sent', chatName, caption: caption || '' }); } catch (e) { res.status(500).json({ error: e.message }); } finally { if (localPath) { try { fs.unlinkSync(localPath); } catch {} } } }); // ── Webhook subscriber store ───────────────────────────────────── const subscribers = []; function fanout(event) { for (const sub of subscribers) { fetch(sub.url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(event), }).then((r) => { console.log(` webhook fanout to ${sub.url}: ${r.status}`); }).catch((e) => { console.log(` webhook fanout to ${sub.url} failed: ${e.message}`); }); } } // ── Webhook endpoints ───────────────────────────────────────── app.post('/webhooks/whatsapp/incoming', (req, res) => { const secret = req.headers['x-webhook-secret']; if (!secret || secret !== WEBHOOK_SECRET) { return res.status(401).json({ error: 'invalid or missing webhook secret' }); } const { sender, text, chatId, timestamp, mediaType } = req.body; if (!sender || !chatId) { return res.status(400).json({ error: 'sender and chatId are required' }); } const event = { sender, text: text || '', chatId, timestamp: timestamp || Date.now(), receivedAt: new Date().toISOString(), mediaType: mediaType || 'text', }; if (mediaType === 'image') { res.json({ status: 'ok', mediaProcessing: 'pending' }); setImmediate(async () => { try { const imagePath = waitForNewImage(event.timestamp); if (!imagePath) { console.log(` media: no new image found for ${chatId} from ${sender}`); fanout({ ...event, mediaStatus: 'not-found' }); return; } const localPath = pullImageFile(imagePath); console.log(` media: pulled ${imagePath} → ${localPath}`); fanoutMedia({ ...event, mediaStatus: 'downloaded' }, localPath); } catch (e) { console.log(` media processing error: ${e.message}`); fanout({ ...event, mediaStatus: 'error', error: e.message }); } }); } else { fanout({ ...event, mediaType: 'text' }); res.json({ status: 'ok' }); } }); app.post('/webhooks/subscribe', (req, res) => { const { url } = req.body; if (!url) return res.status(400).json({ error: 'url required' }); if (!subscribers.find((s) => s.url === url)) { subscribers.push({ url, createdAt: new Date().toISOString() }); } res.json({ status: 'subscribed', url, activeSubscribers: subscribers.length }); }); app.get('/webhooks/subscribers', (_req, res) => { res.json({ subscribers: subscribers.map((s) => ({ url: s.url, createdAt: s.createdAt })) }); }); // ── Media download endpoint ────────────────────────────────── app.get('/download/:token', (req, res) => { const entry = mediaDownloads.get(req.params.token); if (!entry) return res.status(404).json({ error: 'download expired or not found' }); res.download(entry.path, entry.filename); }); // ── Server start ───────────────────────────────────────────── fs.mkdirSync('/tmp/wa-uploads', { recursive: true }); fs.mkdirSync('/tmp/wa-downloads', { recursive: true }); app.listen(PORT, '0.0.0.0', () => { const host = require('os').networkInterfaces(); console.log(`wa-appium-api listening on http://0.0.0.0:${PORT}`); console.log(` Appium server: ${APPIUM_HOST}:${APPIUM_PORT}`); console.log(` Webhook secret: ${WEBHOOK_SECRET === 'dev-secret-change-me' ? 'WARNING: using dev default' : 'configured'}`); });