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:
parent
ccd9f0ddd0
commit
5b7bc23648
16 changed files with 556 additions and 0 deletions
|
|
@ -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
122
README.md
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
11
android-notification-listener/.gitignore
vendored
Normal file
11
android-notification-listener/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
|
/app/build
|
||||||
39
android-notification-listener/app/build.gradle.kts
Normal file
39
android-notification-listener/app/build.gradle.kts
Normal 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")
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">WA Notification Bridge</string>
|
||||||
|
</resources>
|
||||||
4
android-notification-listener/build.gradle.kts
Normal file
4
android-notification-listener/build.gradle.kts
Normal 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
|
||||||
|
}
|
||||||
4
android-notification-listener/gradle.properties
Normal file
4
android-notification-listener/gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
5
android-notification-listener/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
android-notification-listener/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||||
18
android-notification-listener/settings.gradle.kts
Normal file
18
android-notification-listener/settings.gradle.kts
Normal 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")
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'}`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue