commit ccd9f0ddd0dccdc61b83ec2953a862d3ed75245f Author: Eddy de Vink Date: Wed Jul 22 20:40:10 2026 +0200 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d8e6b81 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# USB-modus (geen PHONE_IP nodig): telefoon hangt aan USB van de host +# TCP/IP-modus: zet PHONE_IP op het IP-adres van de telefoon +# PHONE_IP=192.168.5.100 +# ADB_PORT=5555 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8182bc4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,112 @@ +.yolo.json +credentials.json +verzendlijst.md + +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build output +dist/ +build/ + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +logs/ +*.log + +# Runtime data +pids/ +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# Dependency directories +jspm_packages/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Test files +test/fixtures/ +test/sample-pdfs/ +test/output/ + +# Temporary files +temp/ +tmp/ +*.tmp% diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5f62070 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM node:lts-bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + openjdk-17-jdk-headless \ + wget \ + unzip \ + && rm -rf /var/lib/apt/lists/* + +RUN wget -q https://dl.google.com/android/repository/platform-tools-latest-linux.zip \ + -O /tmp/platform-tools.zip \ + && mkdir -p /opt/android-sdk \ + && unzip -q /tmp/platform-tools.zip -d /opt/android-sdk \ + && rm /tmp/platform-tools.zip + +ENV ANDROID_HOME=/opt/android-sdk +ENV PATH=$PATH:$ANDROID_HOME/platform-tools + +RUN npm install -g appium@latest \ + && appium driver install uiautomator2 + +EXPOSE 4723 + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..61e58e0 --- /dev/null +++ b/README.md @@ -0,0 +1,409 @@ +# WhatsApp ADB Container + +Appium 3 + UiAutomator2-driver in Docker, voor geautomatiseerde aansturing van een fysieke Android-telefoon +via ADB. Ontworpen voor de **Nexus 6P** (niet geroot) met WhatsApp. + +Twee containers: + +| Container | Functie | Poort | +|-----------|---------|-------| +| `appium-adb` | ADB-server + Appium | `:4723` | +| `wa-appium-api` | Express REST API (fallback voor wwebjs-api) | `:3010` | + +--- + +## Architectuur + +``` +┌─────────────────────────────┐ ┌─────────────────────────────┐ +│ appium-adb │ │ wa-appium-api │ +│ │ │ │ +│ entrypoint.sh: ────────────┤ │ server.js: │ +│ adb start-server │ │ Express REST API │ +│ adb connect/usb │ │ WebDriverIO → Appium │ +│ wifi_sleep_policy=2 │ │ /client/getChats/:ses │ +│ svc power stayon usb │ │ /chat/fetchMessages/:ses │ +│ dumpsys deviceidle off │ │ /client/sendMessage/:ses │ +│ reconnect loop (30s) │ │ │ +│ appium server :4723 ─────┼─────┤ localhost:4723 │ +│ │ │ │ +└─────────────────────────────┘ └─────────────────────────────┘ + │ USB │ REST (localhost:3010) + ▼ ▼ + ┌──────────────┐ ┌──────────────┐ + │ Nexus 6P │ │ Clients │ + │ (ENU7N15B…) │ │ (scripts, │ + │ WhatsApp │ │ curl, etc) │ + └──────────────┘ └──────────────┘ +``` + +**Fallback-strategie:** De primaire WhatsApp-laag is [[Whatsapp rest-api|wwebjs-api]] (`:3001`) +via WhatsApp Web. Als die sessie wegvalt (geblokkeerd nummer, browser-crash), springt +wa-appium-api (`:3010`) in — die de telefoon direct via USB aanstuurt. + +--- + +## Snelstart + +### USB-modus (telefoon aan de kabel) + +Zet USB debugging aan op de telefoon (Settings > Developer options > USB debugging). + +```bash +# check of de telefoon zichtbaar is +adb devices +# → ENU7N15B14000616 device + +# start beide containers +docker compose up -d + +# smoke test +node smoke-test.js + +# health check +curl http://127.0.0.1:3010/health +``` + +### TCP/IP-modus (telefoon remote) + +```bash +# eenmalig na elke reboot (kabel nodig) +adb usb && adb tcpip 5555 + +# kabel los, start met PHONE_IP +PHONE_IP=192.168.5.100 docker compose up -d +``` + +--- + +## Environment variabelen + +### appium-adb + +| Variabele | Default | Beschrijving | +|------------|---------|-------------| +| `PHONE_IP` | *(leeg)* | IP voor TCP/IP-modus. Leeg = USB-modus | +| `ADB_PORT` | `5555` | ADB-poort (na `adb tcpip `) | + +### wa-appium-api + +| Variabele | Default | Beschrijving | +|---------------|-------------|-------------| +| `APPIUM_HOST` | `127.0.0.1` | Appium server host | +| `APPIUM_PORT` | `4723` | Appium server poort | +| `PORT` | `3010` | REST API luisterpoort | + +Kopieer `.env.example` naar `.env` om defaults aan te passen. + +--- + +## USB-passthrough + +De compose mount `/dev/bus/usb` in de `appium-adb` container. De container draait als root, +dus USB-devices zijn direct toegankelijk. Het ADB-serverproces detecteert USB-hotplug +automatisch. + +**Eerste keer op een nieuwe host:** +1. udev-regel aanmaken: `SUBSYSTEM=="usb", ATTR{idVendor}=="18d1", MODE="0660", GROUP="plugdev"` +2. Gebruiker toevoegen aan `plugdev`-groep +3. Kabel los/koppelen of `chown root:plugdev /dev/bus/usb/XXX/YYY` +4. RSA-vingerafdruk accepteren op de telefoon ("Altijd toestaan") + +--- + +## wa-appium-api (fallback) + +REST API die wwebjs-api-endpoints spiegelt, maar via Appium de Android-telefoon aanstuurt +in plaats van WhatsApp Web. Trager en layout-gevoelig, maar werkt ook als de Web-sessie +geblokkeerd is. + +### Endpoints + +#### `GET /health` + +```bash +curl http://127.0.0.1:3010/health +{"status":"ok","sessionId":"..."} +``` + +#### `GET /client/getChats/:session` + +Haalt alle chats op van het WhatsApp-hoofdscherm. + +```bash +curl http://127.0.0.1:3010/client/getChats/Eddy +{ + "chats": [ + { + "name": "+31 6 12345678", + "lastMessage": "Foto", + "timestamp": "17:28", + "unread": 0 + }, + ... + ] +} +``` + +| Veld | Type | Beschrijving | +|------|------|-------------| +| `name` | string | Contactnaam of telefoonnummer | +| `lastMessage` | string | Laatste bericht preview (leeg bij media) | +| `timestamp` | string | Tijdstip laatste bericht | +| `unread` | int | Aantal ongelezen berichten | + +**Limitaties:** alleen zichtbare chats op het scherm. Scrollen wordt nog niet ondersteund. +Media-berichten tonen lege preview of `NaN`. + +#### `POST /chat/fetchMessages/:session` + +Haalt berichten op uit een specifieke chat. + +```bash +curl -X POST http://127.0.0.1:3010/chat/fetchMessages/Eddy \ + -H 'Content-Type: application/json' \ + -d '{"chatId": "31612345678"}' + +{ + "messages": [ + { + "text": "Hallo!", + "timestamp": "10:00" + }, + ... + ] +} +``` + +| Parameter | Type | Verplicht | Beschrijving | +|-----------|------|-----------|-------------| +| `chatId` | string | ja | Telefoonnummer (met of zonder `@c.us`) of contactnaam | + +**Limitaties:** alleen tekstberichten worden opgehaald (geen media, voice, of system +messages). Alleen zichtbare berichten in de weergave (scrollt niet). + +#### `POST /client/sendMessage/:session` + +Verstuurt een tekstbericht. + +```bash +curl -X POST http://127.0.0.1:3010/client/sendMessage/Eddy \ + -H 'Content-Type: application/json' \ + -d '{ + "chatId": "31612345678", + "content": "Hallo vanuit de API!" + }' + +{"status":"sent","content":"Hallo vanuit de API!"} +``` + +| Parameter | Type | Verplicht | Beschrijving | +|-----------|------|-----------|-------------| +| `chatId` | string | ja | Telefoonnummer (zonder `@c.us`) of contactnaam | +| `content` | string | ja | Berichttekst | + +**Werkt met:** individuele chats. Groepsberichten nog niet getest. + +### Hoe het werkt (intern) + +De API gebruikt WebDriverIO om via Appium de WhatsApp Android-app aan te sturen: + +``` +getChats: startActivity → lees contact_row_container elementen → parse naam/bericht/tijd/ongelezen +openChat: startActivity → klik search_bar_inner_layout → type in search_input → klik contact_row_container +sendMessage: openChat → setValue in entry → klik send → goHome +fetchMessages: openChat → lees main_layout → message_text + date +``` + +**Gebruikte resource-IDs** (WhatsApp v2.25+, kunnen wijzigen per versie): + +| Resource-ID | Functie | +|-------------|---------| +| `com.whatsapp:id/search_bar_inner_layout` | Zoekbalk op home | +| `com.whatsapp:id/search_input` | Zoekveld | +| `com.whatsapp:id/contact_row_container` | Chatrij in lijst | +| `com.whatsapp:id/conversations_row_contact_name` | Contactnaam | +| `com.whatsapp:id/single_msg_tv` | Bericht preview (individueel) | +| `com.whatsapp:id/msg_from_tv` | Bericht preview (community/groep) | +| `com.whatsapp:id/conversations_row_date` | Tijdstip | +| `com.whatsapp:id/conversations_row_unread_indicator` | Ongelezen indicator | +| `com.whatsapp:id/entry` | Tekstinvoerveld | +| `com.whatsapp:id/send` | Verzendknop | +| `com.whatsapp:id/main_layout` | Berichtcontainer in chat | +| `com.whatsapp:id/message_text` | Berichttekst | +| `com.whatsapp:id/date` | Datum/tijd van bericht | + +--- + +## Containers in detail + +### appium-adb + +**Image build:** +- Base: `node:lts-bookworm-slim` +- OpenJDK 17 + Android platform-tools (Google) + Appium 3.x + uiautomator2-driver +- Zie `Dockerfile` + +**Entrypoint (`entrypoint.sh`):** +1. `adb start-server` +2. Detecteer USB (`adb -d`) of TCP (`adb connect`) +3. Pas instellingen toe (non-root): `wifi_sleep_policy=2`, `svc power stayon usb`, `deviceidle disable` +4. Start reconnect loop op achtergrond (elke 30s check of device nog zichtbaar is) +5. `exec appium --address 0.0.0.0 --port 4723 --allow-cors --relaxed-security` + +### wa-appium-api + +**Image build:** +- Base: `node:lts-bookworm-slim` +- Express + WebDriverIO +- Zie `wa-appium-api/Dockerfile` + +**Belangrijke code in `wa-appium-api/server.js`:** +- `getDriver()` — maakt Appium-sessie, herconnect bij drop +- `ensureWhatsApp(d)` — start WhatsApp als het niet op de voorgrond staat +- `goHome(d)` — navigeer naar WhatsApp home +- `openChat(d, query)` — zoek chat via search, open 'm +- Finders gebruiken UiAutomator2 `new UiSelector()` via WebDriverIO + +--- + +## Troubleshooting + +### "Could not find a connected Android device" + +``` +docker logs appium-adb — check of adb connect/usb lukt +``` + +- Zit de USB-kabel er goed in? Check met `adb devices` op de **host**. +- USB debugging aan op de telefoon? (Developer options) +- Nieuwe RSA-key? Kabel los/koppelen en "Altijd toestaan" op telefoon. +- Voor TCP/IP: `adb connect :5555` testen vanaf host. + +### ADB-serverversie-mismatch + +ADB in de container (Google platform-tools) en op de host kunnen verschillen. +Meestal geen probleem. Bij vreemd gedrag: + +```bash +# host ADB updaten +sudo -A dnf install android-tools # Fedora +# of gebruik ADB uit container +docker exec appium-adb adb devices +``` + +### Appium vindt uiautomator2-driver niet + +``` +driver uiautomator2 is not installed +``` + +```bash +docker exec appium-adb appium driver install uiautomator2 +# of rebuild +docker compose build --no-cache appium +``` + +### Telefoon niet meer bereikbaar na reboot + +Na een reboot moet USB debugging opnieuw bevestigd worden (RSA-vingerafdruk): + +```bash +adb devices +# zo niet: kabel los/koppelen, bevestig op telefoon +# voor TCP/IP: adb usb && adb tcpip 5555 +``` + +### Verbinding valt weg na verloop van tijd + +De entrypoint past toe: +``` +settings put global wifi_sleep_policy 2 +svc power stayon usb +dumpsys deviceidle disable +``` + +Bij USB-verbinding zou dit zelden moeten gebeuren. Check: + +1. `docker logs appium-adb` — zie je reconnect-pogingen? +2. **Kabel:** probeer een andere. Goedkope kabels geven drops. +3. **USB-poort:** direct op moederbord, niet via hub. +4. **Stay awake:** zet aan in Developer options op de telefoon. +5. **Settings opnieuw:** `docker restart appium-adb`. +6. **Laatste redmiddel:** rooten + `iw wlan0 set power_save off` (alleen TCP/IP). + +### wa-appium-api fault + +```bash +docker logs wa-appium-api +# als server.js is aangepast, rebuild nodig: +docker compose build wa-appium-api +docker compose up -d wa-appium-api +``` + +### wa-appium-api: session lost + +De `getDriver()` functie herconnect automatisch bij een verbroken sessie. +Als dat niet werkt: + +```bash +docker restart wa-appium-api +``` + +--- + +## Onderhoud + +### Rebuild en restart + +```bash +# alles +docker compose build --no-cache +docker compose up -d + +# alleen Appium (bv. na driver update) +docker compose build --no-cache appium +docker compose up -d appium + +# alleen API (bv. na code wijziging) +docker compose build wa-appium-api +docker compose up -d wa-appium-api +``` + +### Logs + +```bash +docker logs appium-adb -f +docker logs wa-appium-api -f +``` + +### Phone settings resetten + +```bash +docker exec appium-adb adb shell settings put global wifi_sleep_policy 2 +docker exec appium-adb adb shell svc power stayon usb +docker exec appium-adb adb shell dumpsys deviceidle disable +``` + +--- + +## Bestanden + +``` +├── Dockerfile # Image: Node LTS + Java 17 + adb + Appium +├── docker-compose.yml # Service-definities (appium + wa-appium-api) +├── entrypoint.sh # USB/TCP-detectie + settings + reconnect-loop +├── wa-appium-api/ +│ ├── Dockerfile # Image: Node LTS + Express + WebDriverIO +│ ├── package.json # Dependencies +│ └── server.js # Express REST API +├── smoke-test.js # Node.js smoke-test (geen dependencies) +├── .env.example # Voorbeeld env-variabelen +└── README.md # Dit bestand +``` + +## Zie ook + +- [[Whatsapp rest-api]] — primaire WhatsApp API (wwebjs-api, `:3001`) +- [[Android Telefoon]] — Nexus 6P met Termux +- [[Whatsapp Scripts Migratie]] — overzicht WhatsApp automatisering diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c29fa17 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + appium: + build: + context: . + network: host + container_name: appium-adb + network_mode: host + devices: + - "/dev/bus/usb:/dev/bus/usb" + environment: + - PHONE_IP=${PHONE_IP:-} + - ADB_PORT=${ADB_PORT:-5555} + restart: unless-stopped + + wa-appium-api: + build: + context: wa-appium-api + network: host + container_name: wa-appium-api + network_mode: host + environment: + - APPIUM_HOST=127.0.0.1 + - APPIUM_PORT=4723 + - PORT=3010 + restart: unless-stopped diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..a7cbae7 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,66 @@ +#!/bin/bash +set -e + +PHONE_IP="${PHONE_IP:-}" +ADB_PORT="${ADB_PORT:-5555}" + +adb start-server + +if [ -n "$PHONE_IP" ]; then + DEVICE="${PHONE_IP}:${ADB_PORT}" + S="-s $DEVICE" + echo "TCP/IP mode: connecting to $DEVICE..." + ATTEMPT=1 + while [ $ATTEMPT -le 10 ]; do + adb connect "$DEVICE" 2>&1 + if adb $S get-state 2>/dev/null | grep -q device; then break; fi + echo " attempt $ATTEMPT/10 failed, retrying in 3s..." + sleep 3 + ATTEMPT=$((ATTEMPT + 1)) + done + if ! adb $S get-state 2>/dev/null | grep -q device; then + echo "ERROR: Could not connect to $DEVICE after 10 attempts" + exit 1 + fi +else + S="-d" + echo "USB mode: waiting for device..." + ATTEMPT=1 + while [ $ATTEMPT -le 10 ]; do + if adb $S get-state 2>/dev/null | grep -q device; then + SERIAL=$(adb devices | awk '!/List/ && /device$/ {print $1; exit}') + echo "Found device: $SERIAL" + S="-s $SERIAL" + break + fi + echo " attempt $ATTEMPT/10..." + sleep 1 + ATTEMPT=$((ATTEMPT + 1)) + done + if ! adb $S get-state 2>/dev/null | grep -q device; then + echo "ERROR: No USB device found after 10 attempts" + echo "Check USB cable and that USB debugging is enabled on the phone." + exit 1 + fi +fi + +echo "Applying non-root settings..." +adb $S shell settings put global wifi_sleep_policy 2 || echo " (skipped - wifi_sleep_policy)" +adb $S shell svc power stayon usb || echo " (skipped - svc power stayon)" +adb $S shell dumpsys deviceidle disable || echo " (skipped - deviceidle disable)" + +echo "Starting background reconnect loop (every 30s)..." +( + while true; do + sleep 30 + if ! adb $S get-state 2>/dev/null | grep -q device; then + echo "$(date) Device disconnected, reconnecting..." + if [ -n "$PHONE_IP" ]; then + adb connect "${PHONE_IP}:${ADB_PORT}" 2>&1 + fi + fi + done +) & + +echo "Starting Appium on 0.0.0.0:4723..." +exec appium --address 0.0.0.0 --port 4723 --allow-cors --relaxed-security diff --git a/smoke-test.js b/smoke-test.js new file mode 100644 index 0000000..bca5010 --- /dev/null +++ b/smoke-test.js @@ -0,0 +1,83 @@ +const http = require('http'); + +const HOST = process.env.APPIUM_HOST || '127.0.0.1'; +const PORT = parseInt(process.env.APPIUM_PORT || '4723', 10); +const TIMEOUT = 30000; + +function request(method, path, body) { + return new Promise((resolve, reject) => { + const opts = { + hostname: HOST, port: PORT, path, method, + headers: body ? { 'Content-Type': 'application/json' } : {}, + timeout: TIMEOUT, + }; + const req = http.request(opts, (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + try { + const json = JSON.parse(data); + const val = json.value !== undefined ? json.value : json; + if (val && val.error) { + reject(new Error(val.message || val.error)); + } else { + resolve(json); + } + } catch (e) { + reject(new Error(`Response parse error: ${data.slice(0, 300)}`)); + } + }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('Request timed out')); }); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +} + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +async function main() { + console.log(`Connecting to Appium at ${HOST}:${PORT}...`); + + const caps = { + platformName: 'Android', + 'appium:automationName': 'UiAutomator2', + 'appium:deviceName': 'Android', + 'appium:noReset': true, + }; + + console.log('Creating session...'); + const createRes = await request('POST', '/session', { + capabilities: { firstMatch: [caps] }, + }); + const sid = (createRes.value && createRes.value.sessionId) || createRes.sessionId; + if (!sid) throw new Error(`No sessionId in response: ${JSON.stringify(createRes)}`); + console.log(` sessionId: ${sid}`); + + console.log('Taking screenshot...'); + await sleep(1000); + const ssRes = await request('GET', `/session/${sid}/screenshot`); + const ssLen = ssRes.value ? ssRes.value.length : 0; + console.log(` screenshot: ${ssLen} bytes`); + + console.log('Quering current package...'); + try { + const pkgRes = await request('GET', `/session/${sid}/appium/device/current_package`); + console.log(` current package: ${pkgRes.value}`); + } catch (e) { + console.log(` (could not get package: ${e.message})`); + } + + console.log('Deleting session...'); + await request('DELETE', `/session/${sid}`); + + console.log('\nSmoke test PASSED'); +} + +main().catch((err) => { + console.error(`\nSmoke test FAILED: ${err.message}`); + process.exit(1); +}); diff --git a/wa-appium-api/Dockerfile b/wa-appium-api/Dockerfile new file mode 100644 index 0000000..0f1b65a --- /dev/null +++ b/wa-appium-api/Dockerfile @@ -0,0 +1,9 @@ +FROM node:lts-bookworm-slim + +WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev +COPY server.js ./ + +EXPOSE 3002 +CMD ["node", "server.js"] diff --git a/wa-appium-api/package.json b/wa-appium-api/package.json new file mode 100644 index 0000000..aefaf40 --- /dev/null +++ b/wa-appium-api/package.json @@ -0,0 +1,10 @@ +{ + "name": "wa-appium-api", + "version": "1.0.0", + "private": true, + "main": "server.js", + "dependencies": { + "express": "^4.21.0", + "webdriverio": "^8.40.0" + } +} diff --git a/wa-appium-api/server.js b/wa-appium-api/server.js new file mode 100644 index 0000000..472937b --- /dev/null +++ b/wa-appium-api/server.js @@ -0,0 +1,251 @@ +const express = require('express'); +const { remote } = require('webdriverio'); + +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 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); +} + +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); + + // Find and click the matching chat row + 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 no match by name, try clicking first result + if (chats.length > 0) { + await chats[0].click(); + await d.pause(1500); + return; + } + throw new Error(`No chat found for "${query}"`); +} + +// ── Debug ───────────────────────────────────────────────────────── + + + +// ── Endpoints ───────────────────────────────────────────────────── + +app.get('/health', async (_req, res) => { + try { + const d = await getDriver(); + const session = 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 }); + } +}); + +app.listen(PORT, '0.0.0.0', () => { + console.log(`wa-appium-api listening on http://0.0.0.0:${PORT}`); + console.log(` Appium server: ${APPIUM_HOST}:${APPIUM_PORT}`); +});