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
|
||||
# PHONE_IP=192.168.5.100
|
||||
# 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
|
||||
|
||||
### Rebuild en restart
|
||||
|
|
@ -399,6 +514,13 @@ docker exec appium-adb adb shell dumpsys deviceidle disable
|
|||
│ └── server.js # Express REST API
|
||||
├── smoke-test.js # Node.js smoke-test (geen dependencies)
|
||||
├── .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
|
||||
```
|
||||
|
||||
|
|
|
|||
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_PORT=4723
|
||||
- PORT=3010
|
||||
- WEBHOOK_SHARED_SECRET=${WEBHOOK_SHARED_SECRET:-dev-secret-change-me}
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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 WEBHOOK_SECRET = process.env.WEBHOOK_SHARED_SECRET || 'dev-secret-change-me';
|
||||
|
||||
const app = express();
|
||||
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', () => {
|
||||
console.log(`wa-appium-api listening on http://0.0.0.0:${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