feat: notification bridge + webhook system

- New: android-notification-listener/ — Kotlin app vangt WhatsApp-notificaties
- New: POST /webhooks/whatsapp/incoming — ontvangt berichten van de Android-app
- New: POST /webhooks/subscribe + GET /webhooks/subscribers — fan-out
- Updated: docker-compose.yml met WEBHOOK_SHARED_SECRET
- Updated: README met volledige documentatie
This commit is contained in:
Eddy de Vink 2026-07-22 20:57:57 +02:00
commit 5b7bc23648
16 changed files with 556 additions and 0 deletions

View file

@ -2,3 +2,7 @@
# TCP/IP-modus: zet PHONE_IP op het IP-adres van de telefoon # TCP/IP-modus: zet PHONE_IP op het IP-adres van de telefoon
# PHONE_IP=192.168.5.100 # PHONE_IP=192.168.5.100
# ADB_PORT=5555 # ADB_PORT=5555
# Webhook shared secret (Android notification bridge → API)
# Moet overeenkomen met de instelling in de Android-app
# WEBHOOK_SHARED_SECRET=verander-mij

122
README.md
View file

@ -352,6 +352,121 @@ docker restart wa-appium-api
--- ---
## Android Notification Bridge (event-driven)
Dit systeem maakt **real-time** notificatie van binnenkomende WhatsApp-berichten
mogelijk zonder te pollen via Appium. Het bestaat uit twee componenten:
```
WhatsApp → Android Notification → listener-app (APK) → HTTP POST → wa-appium-api → subscribers
```
### Architectuur
1. **Android app** (`android-notification-listener/`) luistert naar WhatsApp-notificaties
2. Bij elk bericht stuurt de app een HTTP POST naar de API-container
3. De API valideert het gedeelde secret en stuurt het bericht door naar subscribers
4. Subscribers registreren zich via een apart endpoint
### Android-app: bouwen en installeren
**Builden** (vereist Android SDK lokaal of op een build-server):
```bash
cd android-notification-listener
./gradlew assembleDebug
adb install app/build/outputs/apk/debug/app-debug.apk
```
Als `gradlew` niet bestaat, genereer het eerst:
```bash
gradle wrapper --gradle-version 8.5
```
**Notificatietoegang verlenen** (eenmalig via ADB):
```bash
adb shell settings put secure enabled_notification_listeners \
com.eddydevink.whatsapplistener/com.eddydevink.whatsapplistener.WhatsAppNotificationListener
```
Verifiëren:
```bash
adb shell settings get secure enabled_notification_listeners
# moet de package/service naam bevatten
```
**Configureren:** open de app op de telefoon ("WA Notification Bridge"),
vul het webhook-URL en shared secret in.
### Webhook configuratie (API-zijde)
Zet in `.env` of als environment variable:
```bash
WEBHOOK_SHARED_SECRET=verander-mij # moet gelijk zijn aan de Android-app
```
### Webhook endpoints
#### `POST /webhooks/whatsapp/incoming`
Ontvangt berichten van de Android notification-listener.
```bash
curl -X POST http://127.0.0.1:3010/webhooks/whatsapp/incoming \
-H 'Content-Type: application/json' \
-H 'X-Webhook-Secret: verander-mij' \
-d '{
"sender": "+31 6 12345678",
"text": "Hallo!",
"chatId": "Eddy de Vink",
"timestamp": 1700000000000
}'
```
| Header | Waarde |
|--------|--------|
| `X-Webhook-Secret` | Gedeeld geheim (moet matchen met `WEBHOOK_SHARED_SECRET`) |
| Veld | Type | Verplicht | Beschrijving |
|------|------|-----------|-------------|
| `sender` | string | ja | Naam of nummer van afzender |
| `text` | string | ja | Berichttekst |
| `chatId` | string | ja | Chat-naam zoals getoond in notificatie |
| `timestamp` | long | nee | Unix timestamp (ms) |
#### `POST /webhooks/subscribe`
Registreer een subscriber (interne service die notificaties wil ontvangen).
```bash
curl -X POST http://127.0.0.1:3010/webhooks/subscribe \
-H 'Content-Type: application/json' \
-d '{"url": "http://192.168.5.252:3003/webhook"}'
```
#### `GET /webhooks/subscribers`
Lijst van actieve subscribers.
```bash
curl http://127.0.0.1:3010/webhooks/subscribers
```
### Beperkingen
- **Samengevoegde notificaties:** als meerdere berichten in dezelfde chat
binnenkomen voordat de eerste is verwerkt, kan Android één samengevoegde
notificatie sturen. De listener verwerkt dan alle `messages` uit de
`MessagingStyle`, dus individuele berichten gaan niet verloren.
- **Alleen push-notificaties:** berichten die binnenkomen terwijl de app
open is (dus geen notificatie genereren) worden niet gevangen. Gebruik
dan Appium-polling als aanvulling.
- **Eén telefoon, één listener:** de app luistert naar alle chats.
---
## Onderhoud ## Onderhoud
### Rebuild en restart ### Rebuild en restart
@ -399,6 +514,13 @@ docker exec appium-adb adb shell dumpsys deviceidle disable
│ └── server.js # Express REST API │ └── server.js # Express REST API
├── smoke-test.js # Node.js smoke-test (geen dependencies) ├── smoke-test.js # Node.js smoke-test (geen dependencies)
├── .env.example # Voorbeeld env-variabelen ├── .env.example # Voorbeeld env-variabelen
├── android-notification-listener/ # Android-app: notificaties → webhook
│ ├── app/src/main/java/.../ # Kotlin broncode
│ │ ├── SettingsActivity.kt
│ │ ├── WhatsAppNotificationListener.kt
│ │ └── WebhookClient.kt
│ ├── build.gradle.kts
│ └── settings.gradle.kts
└── README.md # Dit bestand └── README.md # Dit bestand
``` ```

View file

@ -0,0 +1,11 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
/app/build

View file

@ -0,0 +1,39 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.eddydevink.whatsapplistener"
compileSdk = 34
defaultConfig {
applicationId = "com.eddydevink.whatsapplistener"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.preference:preference-ktx:1.2.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
}

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.AppCompat.Light.DarkActionBar">
<activity
android:name=".SettingsActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".WhatsAppNotificationListener"
android:exported="true"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
</application>
</manifest>

View file

@ -0,0 +1,62 @@
package com.eddydevink.whatsapplistener
import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import android.service.notification.NotificationListenerService
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.PreferenceManager
class SettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
val webhookUrlInput = findViewById<EditText>(R.id.webhookUrl)
val secretInput = findViewById<EditText>(R.id.sharedSecret)
val saveButton = findViewById<Button>(R.id.saveButton)
val statusText = findViewById<TextView>(R.id.statusText)
webhookUrlInput.setText(prefs.getString("webhook_url", ""))
secretInput.setText(prefs.getString("shared_secret", ""))
updateStatus(statusText)
saveButton.setOnClickListener {
prefs.edit()
.putString("webhook_url", webhookUrlInput.text.toString().trim())
.putString("shared_secret", secretInput.text.toString().trim())
.apply()
statusText.text = "Opgeslagen."
}
statusText.setOnClickListener {
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
}
}
private fun updateStatus(statusText: TextView) {
val enabled = isNotificationListenerEnabled()
if (enabled) {
statusText.text = "✅ Notificatietoegang: VERLEEND"
statusText.setTextColor(0xFF2E7D32.toInt())
} else {
statusText.text = "⚠️ Notificatietoegang: NIET VERLEEND — tik hier om in te stellen"
statusText.setTextColor(0xFFC62828.toInt())
}
}
private fun isNotificationListenerEnabled(): Boolean {
val flat = Settings.Secure.getString(
contentResolver,
"enabled_notification_listeners"
) ?: return false
return flat.contains(packageName + "/" + WhatsAppNotificationListener::class.java.name)
}
}

View file

@ -0,0 +1,65 @@
package com.eddydevink.whatsapplistener
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.util.concurrent.TimeUnit
object WebhookClient {
private val client = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.build()
private val JSON = "application/json".toMediaType()
fun postMessage(
webhookUrl: String,
secret: String,
sender: String,
text: String,
chatId: String,
timestamp: Long,
onResult: (Boolean) -> Unit
) {
val body = JSONObject().apply {
put("sender", sender)
put("text", text)
put("chatId", chatId)
put("timestamp", timestamp)
}.toString()
val request = Request.Builder()
.url(webhookUrl)
.addHeader("Content-Type", "application/json")
.addHeader("X-Webhook-Secret", secret)
.post(body.toRequestBody(JSON))
.build()
var attempt = 0
val maxAttempts = 5
var delay = 1000L
fun trySend() {
attempt++
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
onResult(true)
return
}
if (attempt >= maxAttempts) {
onResult(false)
return
}
Thread.sleep(delay)
delay = (delay * 2).coerceAtMost(30_000L)
trySend()
}
}
Thread { trySend() }.start()
}
}

View file

@ -0,0 +1,54 @@
package com.eddydevink.whatsapplistener
import android.content.SharedPreferences
import android.os.Bundle
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat
import androidx.preference.PreferenceManager
class WhatsAppNotificationListener : NotificationListenerService() {
private lateinit var prefs: SharedPreferences
override fun onCreate() {
super.onCreate()
prefs = PreferenceManager.getDefaultSharedPreferences(this)
}
override fun onNotificationPosted(sbn: StatusBarNotification) {
if (sbn.packageName != "com.whatsapp") return
val webhookUrl = prefs.getString("webhook_url", "") ?: ""
val secret = prefs.getString("shared_secret", "") ?: ""
if (webhookUrl.isBlank() || secret.isBlank()) return
val messagingStyle = NotificationCompat.MessagingStyle
.extractMessagingStyleFromNotification(sbn.notification) ?: return
val chatId = messagingStyle.title ?: "Unknown"
val messages = messagingStyle.messages ?: return
for (msg in messages) {
val senderName = msg.sender?.name?.toString() ?: "Unknown"
val text = msg.text?.toString() ?: ""
val timestamp = msg.timestamp
WebhookClient.postMessage(
webhookUrl = webhookUrl,
secret = secret,
sender = senderName,
text = text,
chatId = chatId,
timestamp = timestamp,
) { success ->
android.util.Log.d(
"WANotificationBridge",
"Sent message from $senderName: ${if (success) "OK" else "FAILED after retries"}"
)
}
}
}
override fun onNotificationRemoved(sbn: StatusBarNotification) {}
}

View file

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="24dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="WhatsApp Notification Bridge"
android:textSize="20sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Stuurt binnenkomende WhatsApp-berichten door naar een webhook-endpoint."
android:textSize="14sp"
android:textColor="#666"
android:layout_marginBottom="24dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Webhook URL"
android:textStyle="bold"
android:layout_marginBottom="4dp" />
<EditText
android:id="@+id/webhookUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="http://192.168.5.252:3010/webhooks/whatsapp/incoming"
android:inputType="textUri"
android:autofillHints="no"
android:importantForAutofill="no" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Shared Secret"
android:textStyle="bold"
android:layout_marginTop="16dp"
android:layout_marginBottom="4dp" />
<EditText
android:id="@+id/sharedSecret"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Kies een geheim wachtwoord"
android:inputType="textPassword"
android:autofillHints="no"
android:importantForAutofill="no" />
<Button
android:id="@+id/saveButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Opslaan" />
<TextView
android:id="@+id/statusText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textSize="14sp" />
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">WA Notification Bridge</string>
</resources>

View file

@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
}

View file

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

View file

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View file

@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "WhatsAppNotificationListener"
include(":app")

View file

@ -22,4 +22,5 @@ services:
- APPIUM_HOST=127.0.0.1 - APPIUM_HOST=127.0.0.1
- APPIUM_PORT=4723 - APPIUM_PORT=4723
- PORT=3010 - PORT=3010
- WEBHOOK_SHARED_SECRET=${WEBHOOK_SHARED_SECRET:-dev-secret-change-me}
restart: unless-stopped restart: unless-stopped

View file

@ -4,6 +4,7 @@ const { remote } = require('webdriverio');
const APPIUM_HOST = process.env.APPIUM_HOST || '127.0.0.1'; const APPIUM_HOST = process.env.APPIUM_HOST || '127.0.0.1';
const APPIUM_PORT = parseInt(process.env.APPIUM_PORT || '4723', 10); const APPIUM_PORT = parseInt(process.env.APPIUM_PORT || '4723', 10);
const PORT = parseInt(process.env.PORT || '3010', 10); const PORT = parseInt(process.env.PORT || '3010', 10);
const WEBHOOK_SECRET = process.env.WEBHOOK_SHARED_SECRET || 'dev-secret-change-me';
const app = express(); const app = express();
app.use(express.json({ limit: '10mb' })); app.use(express.json({ limit: '10mb' }));
@ -245,7 +246,60 @@ app.post('/client/sendMessage/:session', async (req, res) => {
} }
}); });
// ── Webhook subscriber store (in-memory) ─────────────────────
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 } = req.body;
if (!sender || !text || !chatId) {
return res.status(400).json({ error: 'sender, text, and chatId are required' });
}
const event = { sender, text, chatId, timestamp: timestamp || Date.now(), receivedAt: new Date().toISOString() };
fanout(event);
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 })) });
});
// ── Server start ─────────────────────────────────────────────
app.listen(PORT, '0.0.0.0', () => { app.listen(PORT, '0.0.0.0', () => {
console.log(`wa-appium-api listening on http://0.0.0.0:${PORT}`); console.log(`wa-appium-api listening on http://0.0.0.0:${PORT}`);
console.log(` Appium server: ${APPIUM_HOST}:${APPIUM_PORT}`); console.log(` Appium server: ${APPIUM_HOST}:${APPIUM_PORT}`);
console.log(` Webhook secret: ${WEBHOOK_SECRET === 'dev-secret-change-me' ? 'WARNING: using dev default' : 'configured'}`);
}); });