Initial commit
This commit is contained in:
commit
ccd9f0ddd0
10 changed files with 995 additions and 0 deletions
4
.env.example
Normal file
4
.env.example
Normal 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
112
.gitignore
vendored
Normal 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
26
Dockerfile
Normal 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
409
README.md
Normal 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
25
docker-compose.yml
Normal 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
66
entrypoint.sh
Executable 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
83
smoke-test.js
Normal 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
9
wa-appium-api/Dockerfile
Normal 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"]
|
||||||
10
wa-appium-api/package.json
Normal file
10
wa-appium-api/package.json
Normal 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
251
wa-appium-api/server.js
Normal 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}`);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue