Initial commit

This commit is contained in:
Eddy de Vink 2026-07-22 20:40:10 +02:00
commit ccd9f0ddd0
10 changed files with 995 additions and 0 deletions

4
.env.example Normal file
View file

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

112
.gitignore vendored Normal file
View file

@ -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%

26
Dockerfile Normal file
View file

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

409
README.md Normal file
View file

@ -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 <poort>`) |
### 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 <IP>: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

25
docker-compose.yml Normal file
View file

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

66
entrypoint.sh Executable file
View file

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

83
smoke-test.js Normal file
View file

@ -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);
});

9
wa-appium-api/Dockerfile Normal file
View file

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

View file

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

251
wa-appium-api/server.js Normal file
View file

@ -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}`);
});