From c13ec813799fc8adf5571ec5e6da0aef2b21cd14 Mon Sep 17 00:00:00 2001 From: Eddy de Vink Date: Wed, 9 Sep 2026 23:46:22 +0200 Subject: [PATCH] =?UTF-8?q?v1.8.1:=20idempotente=20start()=20=E2=80=94=20g?= =?UTF-8?q?uard=20voorkomt=20dubbele=20poll-loop=20bij=20maubot=20double-s?= =?UTF-8?q?tart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WHATSAPP_DOORSTUREN_PLAN.md | 170 ++++++++++++++++++++++++++++++++++++ buzzrss/bot.py | 11 +++ maubot.yaml | 2 +- 3 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 WHATSAPP_DOORSTUREN_PLAN.md diff --git a/WHATSAPP_DOORSTUREN_PLAN.md b/WHATSAPP_DOORSTUREN_PLAN.md new file mode 100644 index 0000000..8439d45 --- /dev/null +++ b/WHATSAPP_DOORSTUREN_PLAN.md @@ -0,0 +1,170 @@ +# buzz-rss: nieuwsfeed-URLs doorsturen naar WhatsApp-groep "OV in de media" + +## Context + +De maubot-plugin `nl.eddydevink.rss` (bron: `/Users/eddy/src/buzz-rss`, draait als instance `rss` in `matrix-bot-maubot` op nextcloud) filtert nieuwsfeeds op OV-trefwoorden (`match_filter`) en plaatst ze per feed-subscription in Matrix-rooms. De wens: **alleen de link** van dezelfde geplaatste berichten óók plaatsen in de WhatsApp-groep **"OV in de media"** in de community "EBS Klets en Diensten opvraag", via de bestaande wwebjs-api op nextcloud (`:3001`). + +Doelgroep-ID (vastgesteld via de DevTools-chatcollectie van de Eddy-sessie-browser): **`120363429643087130@g.us`** (type `LINKED_SUBGROUP`, titel "OV in de media"). De Verkeersberichten-groep (`120363427013472489@g.us`) is een andere groep en blijft voor EBS-verkeersberichten. + + + +**Read-only-route voor de dedup-check:** `fetchMessages`, `getChats` en `getChatById` falen in de aangepaste container met `{"success":false,"error":"r"}` (fout uit de browser-evaluate. De werkende read-route is **`POST /client/searchMessages/{session}`** — getest tegen de doelgroep: substring-query op de bericht-body; de response bevat `messages[]` met `_data.body` (berichttekst) en `_data.id.remote` (chat-id van het bericht. Alleen deze read-route en `sendMessage` worden gebruikt. Een `searchMessages`-query zonder match retourneert `{"success":true,"messages":[]}`. + +. Deze foutsymptomen en workaround zijn gedocumenteerd in de Obsidian-vault: `[[Whatsapp rest-api]]` → sectie `Troubleshooting: error r op read-endpoints`. Raadpleeg die notitie bij het tegenkomen van deze fout (of `client/searchMessages` ineens faalt). + + + +**Hard verbod tijdens uitvoering:** de wwebjs-api container NOOIT herstarten en geen WhatsApp-sessie beëindigen/restarten. Alleen de maubot-instance `rss` mag herladen (eigen container, geen WhatsApp-sessies.) WhatsApp-kanaal = uitsluitend de wwebjs-api HTTP-API op `http://192.168.5.252:3001` (bereikbaarheid vanuit de maubot-container is geverifieerd: `/ping` → pong). Read-only container-inspectie (docker exec cat/logs, curl GET/POST tegen de API) is toegestaan; sessie-/container-management niet. + + + +## Approach + +Alle wijzigingen in `/Users/eddy/src/buzz-rss`. Er is geen test-suite in de repo; er is geen bestaande WhatsApp-forward-functie (de EBS-verkeersberichten-bot is een los Node-script, geen herbruikbare bibliotheek — NIET importeren, alleen als voorbeeld van de wwebjs-API-call). + + + +1. **Config-keys toevoegen** — `buzzrss/bot.py`, class `Config.do_update` (rond regel 44-56): vóór `helper.copy("admins")` toevoegen: + ```python + helper.copy("whatsapp_api") + helper.copy("whatsapp_session") + helper.copy("whatsapp_chat_id") + ``` + Koppel daaraan in `base-config.yaml` (achter `run_log_file`, vóór `exclude_paywalled` — volgorde in YAML maakt niet uit, wel alle drie toevoegen): + ```yaml + # Doorsturen naar WhatsApp (wwebjs-api op nextcloud :3001). Leeg whatsapp_api/whatsapp_chat_id = uit. Alleen de link wordt gepost (geen titel. + whatsapp_api: http://192.168.5.252:3001 + whatsapp_session: EdGpt + whatsapp_chat_id: 120363429643087130@g.us + ``` + Geen `whatsapp_notification_template`: het WhatsApp-bericht is uitsluitend de onbewerkte `entry.link` (WhatsApp maakt daar automatisch link-preview van; geen Markdown-links. Een ander formaat later = code-wijziging nodig. + + + +2. **Nieuwe methode `_send_whatsapp`** in `RSSBot` (`buzzrss/bot.py`, naast `_send`, rond regel 200). **Dedup-check is verplicht vóór elke post; faalt de check, dan wordt niet gepost.** `self.http` is de bestaande aiohttp-sessie (`self.client.api.session`, al gebruikt voor feed-fetches met absolute URL's). Foutafhandeling: API-error of netwerkfout → log + `False`, geen crash; niet-geconfigureerd → `False` zonder fout: + ```python + async def _send_whatsapp(self, feed: Feed, entry: Entry) -> bool: + """Post entry.link naar de WhatsApp-groep via wwebjs-api (met dedup-check). True = nieuw geplaatst.""" + api = self.config.get("whatsapp_api", "") + chat_id = self.config.get("whatsapp_chat_id", "") + if not api or not chat_id: + self.log.debug("WhatsApp forwarding disabled") + return False + link = entry.link + if not link: + self.log.warning(f"No link for {entry.id}, skipping WhatsApp") + return False + session = self.config.get("whatsapp_session", "EdGpt") + base = api.rstrip("/") + # Dedup: sla over als de URL al in de doelgroep staat. Search is globaal + # over alle chats; filter op chat-id van het gevonden bericht. + + try: + resp = await self.http.post( + f"{base}/client/searchMessages/{session}", + json={"query": link, "options": {"limit": 1}}, + timeout=aiohttp.ClientTimeout(total=20), + ) + data = await resp.json() + if data.get("success"): + for msg in data.get("messages", []): + msg_data = msg.get("_data", {}).get("id", {}) + if ( + msg_data.get("remote") == chat_id + and link in msg.get("_data", {}).get("body", "") + ): + self.log.info(f"{entry.id} already in WhatsApp group, skipping") + return False + except Exception as e: + self.log.warning(f"WhatsApp dedup check failed for {entry.id}: {e}") + return False + try: + resp = await self.http.post( + f"{base}/client/sendMessage/{session}", + json={"chatId": chat_id, "content": link, "contentType": "string"}, + timeout=aiohttp.ClientTimeout(total=20), + ) + data = await resp.json() + ok = bool(data.get("success")) + self.log.info(f"WhatsApp post {entry.id} to {chat_id}: {'ok' if ok else 'failed: ' + str(data)}") + return ok + except Exception as e: + self.log.warning(f"Failed to send {entry.id} to WhatsApp {chat_id}: {e}") + return False + ``` + + + +3. **`_broadcast` uitbreiden** (regel ~235): ná de bestaande filter-check en ná de matrix-verzendlus, vlak vóór de `return`, één WhatsApp-send per entry (niet per subscription): + ```python + sent = sum(1 for resultin results if result is not None) + whatsapp_ok = await self._send_whatsapp(feed, entry) + return (1 if (sent or whatsapp_ok) else 0, 0, len(results) - sent) + ``` + Concreet:de huidige regel `return (1 if sent else 0, 0, len(results) - sent)` vervangen door de drie regels hierboven. Docstring-regel "placed = 1 when at least one room received the message" aanpassen naar "placed = 1 when the entry was posted to at least one channel (Matrix room or newly posted WhatsApp link)". De `failed`-component blijft het aantal gefaalde Matrix-subscriptions. WhatsApp-skip (URL al aanwezig) en WhatsApp-fouten loggen zelf; ze mogen de bestaande telling niet vertekenen. Filter-semantiek ongewijzigd: alleen entries die de filter-pas halen komen in `_send_whatsapp` terecht. + + + +4. **Versie bump** — `maubot.yaml`: `version: 1.5.4` → `version: 1.6.0`. + + + +## Critical files & anchors + +- `buzzrss/bot.py` — `Config.do_update` (regel ~44), `_send` (~200), `_broadcast` (~225-250). Imports reeds aanwezig (`Template`, `aiohttp`, `attr`, `Feed`, `Entry`; `Template` blijft voor de Matrix-notification-template. Implementer herleest de regio's vóór het editen. + +- `base-config.yaml` — extra_files van het mbp; ontbreken = `BaseMissingError` bij start. + + + +- `maubot.yaml` — versie voor de mbp-naam en uploadlaar. + + + + + +## Verification + +Volgorde na elke stap doorvoeren; wacht NIET op project-brede test (geen suite}). Werk: `/Users/eddy/src/buzz-rss`. Nooit wwebjs/sessie-acties — alleen maubot-management-API en de wwebjs HTTP-API (searchMessages/sendMessage). + + + +1. **Compile-check** na de code-wijziging: `python3 -m py_compile buzzrss/bot.py` → exit 0. + + + +2. **Deploy volgens maubot-skill-recept** (skill `maubot` lezen vóór uitvoeren; `MAUBOT_ADMIN_PASSWORD` uit vault-notitie "Matrix Synapse en MAS.md" → secrets-tabel, NOOIT in chat/plan): + - Bronmap (met `buzzrss/`, `maubot.yaml`, `base-config.yaml`) via tar+base64 naar de container brengen in `/data/deploytmp/`; `mbc build` in die werkmap (géén `-o`); verifieer `.mbp`-inhoud: `unzip -l … | grep base-config`. + - Upload (`POST /plugins/upload?allow_override=true`) → `POST /plugin/nl.eddydevink.rss/reload` → `PUT /instance/rss` met `{"enabled": true, "started": true}` → `GET /plugin/nl.eddydevink.rss` toont versie 1.6.0 en beide `true`. + + + +3. **Poll draait**: na reload binnen ~60 s één verse regel `run complete …` in `/matrix/maubot/data/buzzrss-runs.log` (`ssh aiaiaiaiagent@nextcloud 'sudo -A tail …'`); géén verdubbelde regels met dezelfde timestamp. Bewijst dat de nieuwe code startte (start-fout zou de instance op `enabled=False` zetten + geen run-log.). + + + +4. **End-to-end WhatsApp-kanaal** (nieuw gedrag, concrete input → observable output): throwaway-script (lokaal, daarna verwijderen) dat EXACT dezelfde volgorde doet als `_send_whatsapp`, tegen dezelfde API en sessie: + + - **4a — dedup-check (lege verwachting):** `POST http://192.168.5.252:3001/client/searchMessages/EdGpt` met body `{"query": "https://www.ovpro.nl/", "options": {"limit": 1}}}`. Verwacht: `{"success":true,"messages":[]}` (URL nog niet in de groep.) Als die er WEL al instaat: stap 4b slaat het posten over en is de dedup-check daarmee bewezen — ga door naar stap 5. + - **4b — post alleen de link:** `POST …/client/sendMessage/EdGpt` met body `{"chatId": "120363429643087130@g.us", "content": "https://www.ovpro.nl/", "contentType": "string"}`. Verwacht: `{"success":true}`; het bericht (alleen de URL) is zichtbaar in de groep (gebruiker kan bevestigen. Geen titel in het bericht. +. + - **4c — dedup-check (verse post vindbaar):** herhaal 4a → verwacht exact 1 match met `_data.id.remote == "120363429643087130@g.us"` en `_data.body` die de URL bevat. Bewijst dat de zoekfunctie een net geposte link direct vindt (geen index-vertraging. **Vindt 4c de verse post niet direct, dan is de dedup-check onbetrouwbaar → stoppen en overleggen met de gebruiker vóór verdere uitvoering** (geen post zonder werkende check.). + - **Contingentie (pre-gedecideerd):** als de call faalt met een lidmaatschapsfout (EdGpt kan niet posten in die subgroep), zet dan in de maubot-instance-config `whatsapp_session: Eddy` (in de web-UI config-editor of `PUT /config/…`), herstart daarna alleen de instance `rss`, en herhaal deze stap. De sessie-Eddy heeft de groep in zijn chatcollectie (bewezen tijdens onderzoek). + + + +5. **Plugin-pad zelf**: de eerstvolgende poll die een nieuw OV-match-item vindt produceert in maubot-logs de regel `WhatsApp post … ok` (of `… already in WhatsApp group, skipping` bij een al geposte URL) én in de Matrix-room. Komt er (nog) geen nieuw item, dan is stap 4 het bewijs van de koppeling; de plugin-code is door stap 1-3 geverifieerd. Verwijder hierna het throwaway-script. + + + +## Assumptions & contingencies + +- **Berichtformaat**: uitsluitend de onbewerkte `entry.link`; geen titel, geen template-config. WhatsApp maakt automatisch link-preview van de URL. Een ander formaat later = code-wijziging nodig (bewuste keuze volgend uit de eis "Alleen de link moet worden gepost, niet de titel". +- **Dedup-check**: verplicht vóór elke post. De search (`client/searchMessages`) doorzoekt globaal alle chats en matcht **substring** op de bericht-body; de filter op `_data.id.remote == chat_id` (plus `entry.link` in body als extra beveiliging) beperkt tot de doelgroep. Exacte-string-semantiek: een eerdere post met een **variant** van dezelfde link (bijv. andere scheme, `www`, trailing slash, utm-params) wordt niet als duplicaat herkend, omdat de body die variant bevat en de query (`entry.link`) geen substring is van die variant. Acceptabel: we posten exact dezelfde string die we zoeken; een eerdere post van dezelfde entry heeft dezelfde link. +. + +- **Check-fout → geen post**: faalt de dedup-search (netwerk/API-fout), dan wordt de entry **niet** naar WhatsApp gepost (`return False`, warning-log). Dit volgt uit de vereiste "alvorens te posten moet worden gecheckt of de url nog niet in de whatsappgroep geplaatst is". +- **Sessie**: default `EdGpt` (de bot-sessie, zelfde als andere EBS-bots). Valt de test-send in stap 4 uit op lidmaatschap van de subgroep → zie stap 4-contingentie (sessie `Eddy`). +- **Filter**: WhatsApp-forward krijgt exact dezelfde filter-pas als Matrix — er wordt niets extra's gefilterd of doorgelaten; geen aparte whatsapp-filter. + +- **Herstarten/beëindigen van de wwebjs-container of WhatsApp-sessies is ten strengste verboden**; alles verloopt via HTTP-API (`searchMessages` + `sendMessage`) en de maubot-management-API. \ No newline at end of file diff --git a/buzzrss/bot.py b/buzzrss/bot.py index 248ebac..86c26cb 100644 --- a/buzzrss/bot.py +++ b/buzzrss/bot.py @@ -123,6 +123,7 @@ class RSSBot(Plugin): dbm: DBManager exclude_paywalled: bool = False poll_task: asyncio.Future + _poll_started: bool = False poll_sema: asyncio.Semaphore | None _run_logger: logging.Logger | None = None _run_handler: logging.FileHandler | None = None @@ -138,6 +139,15 @@ class RSSBot(Plugin): return upgrade_table async def start(self) -> None: + # Idempotentie-guard: maubot kan internal_start() (her)aanroepen via + # boot/client/upload/update-paden; Instance.start() zet zijn eigen + # started-flag pas ná plugin.start(). Zonder guard ontstaat bij een + # tweede (concurrente) aanroep een tweede poll-loop. Check+set is + # synchronous (geen await ertussen) → atoom in de event-loop. + if self._poll_started: + self.log.warning("start() called twice, ignoring duplicate poll task") + return + self._poll_started = True await super().start() self.on_external_config_update() self.dbm = DBManager(self.database) @@ -177,6 +187,7 @@ class RSSBot(Plugin): async def stop(self) -> None: await super().stop() + self._poll_started = False self.poll_task.cancel() if self._run_handler: self._run_handler.close() diff --git a/maubot.yaml b/maubot.yaml index f5fa9ce..5866a4c 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,5 +1,5 @@ id: nl.eddydevink.rss -version: 1.8.0 +version: 1.8.1 modules: - buzzrss main_class: RSSBot