Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4ea969f33 | ||
|
|
c13ec81379 | ||
|
|
6264e73441 | ||
|
|
d7ba8a06de |
8 changed files with 452 additions and 110 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
170
WHATSAPP_DOORSTUREN_PLAN.md
Normal file
170
WHATSAPP_DOORSTUREN_PLAN.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -16,12 +16,21 @@ notification_template: "New post in $feed_title: [$title]($link)"
|
|||
# Absoluut pad in de container aanraden: /data is de persistente maubot-mount (host: /matrix/maubot/data).
|
||||
# Leeg = uitgeschakeld.
|
||||
run_log_file: /data/buzzrss-runs.log
|
||||
# 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
|
||||
# Exclude paywalled articles (Telegraaf/NHD markeren dit met <premium>true</premium>)
|
||||
exclude_paywalled: true
|
||||
# Global match filter (whitelist): applies to ALL feeds in this instance.
|
||||
# A post is ONLY sent when the regex matches the title OR the (HTML-stripped) summary.
|
||||
# Set it live with `!rss set-filter <regex>`. Empty = send everything.
|
||||
match_filter: "(?i)\\b(?:ns|arriva|qbuzz|connexxion|cxx|transdev|keolis|ret|gvb|ebs|htm|syntus|breng|u-ov|allgo|flixbus|blauwnet|r-net|valleilijn|waterbus|eurostar|thalys|randstadrail|gvu|ov)\\b|\\b(?:bus|tram|metro)(?:halte|haltes|lijn|lijnen|station|stations|verkeer|net|staking|sen)?\\b|\\btrein(?:en|verkeer|stel(?:len)?|staking|ongeluk|reizigers?|station|stations)?\\b|\\b(?:spoorweg|spoorbaan|spoorlijn|spoorverkeer|spoorwegovergang|spoorstoring|perron|perrons|overweg|overwegen|seinstoring|wisselstoring|sein|ontsporing|ontspoord|spoorstaking)\\b|\\b(?:openbaar vervoer|dienstregeling|reizigers?|inchecken|uitchecken|pont|ponten|lightrail|pendelbus|shuttlebus)\\b|\\b(?:ov-chipkaart|chipkaart|ovpay|ov-pay|reisproduct|studentenreisproduct|vervoersbewijs|vervoerbewijs|opwaarderen)\\b|\\b(?:ov-staking|conducteursstaking)\\b"
|
||||
# Titel-dedup over feeds: skip een artikel waarvan de genormaliseerde titel al is gepost
|
||||
# binnen X dagen, ook als de feed/URL anders is (zelfde uitgever, meerdere sites).
|
||||
# 0 = uit.
|
||||
title_dedup_days: 30
|
||||
# OV-matchfilter: VAST in code (gewogen families + blacklist), niet via config te wijzigen.
|
||||
# Doorlaten als 2×(titelscore) + 1×(samenvattingscore) >= 3. Zie de families en de
|
||||
# blacklist in buzzrss/bot.py (MATCH_FILTER_FAMILIES / MATCH_FILTER_BLACKLIST).
|
||||
# `!rss set-filter` en `!rss filter` tonen alleen de huidige (vaste) filter.
|
||||
# Users who can bypass room permission checks
|
||||
admins:
|
||||
- "@user:example.com"
|
||||
Binary file not shown.
304
buzzrss/bot.py
304
buzzrss/bot.py
|
|
@ -16,7 +16,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from string import Template
|
||||
from time import mktime, monotonic, time
|
||||
import asyncio
|
||||
|
|
@ -43,22 +43,66 @@ from mautrix.types import (
|
|||
from mautrix.util.async_db import UpgradeTable
|
||||
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
|
||||
|
||||
from .db import DBManager, Entry, Feed, Subscription
|
||||
from .db import DBManager, Entry, Feed, Subscription, normalize_title
|
||||
from .migrations import upgrade_table
|
||||
|
||||
rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STATE)
|
||||
|
||||
|
||||
DEFAULT_MATCH_FILTER = (
|
||||
r"(?i)"
|
||||
r"\b(?:ns|arriva|qbuzz|connexxion|cxx|transdev|keolis|ret|gvb|ebs|htm|syntus|breng|u-ov|allgo|flixbus|blauwnet|r-net|valleilijn|waterbus|eurostar|thalys|randstadrail|gvu|ov)\b"
|
||||
r"|\b(?:bus|tram|metro)(?:halte|haltes|lijn|lijnen|station|stations|verkeer|net|staking|sen)?\b"
|
||||
r"|\btrein(?:en|verkeer|stel(?:len)?|staking|ongeluk|reizigers?|station|stations)?\b"
|
||||
r"|\b(?:spoorweg|spoorbaan|spoorlijn|spoorverkeer|spoorwegovergang|spoorstoring|perron|perrons|overweg|overwegen|seinstoring|wisselstoring|sein|ontsporing|ontspoord|spoorstaking)\b"
|
||||
r"|\b(?:openbaar vervoer|dienstregeling|reizigers?|inchecken|uitchecken|pont|ponten|lightrail|pendelbus|shuttlebus)\b"
|
||||
r"|\b(?:ov-chipkaart|chipkaart|ovpay|ov-pay|reisproduct|studentenreisproduct|vervoersbewijs|vervoerbewijs|opwaarderen)\b"
|
||||
r"|\b(?:ov-staking|conducteursstaking)\b"
|
||||
)
|
||||
# Gewogen OV-trefwoord-families voor het match-filter. Elke tuple = (regex, gewicht).
|
||||
# Per familie wordt 1× geteld per tekst (niet per cluster/herhaling). Drempel (3):
|
||||
# doorlaten als 2×(titelscore) + 1×(samenvattingscore) >= MATCH_FILTER_THRESHOLD
|
||||
# Gewicht 3 (sterk, onmiskenbaar OV): één woord in titel óf samenvatting volstaat.
|
||||
# Gewicht 2 (medium): één woord in de titel volstaat; in de samenvatting moet er
|
||||
# een tweede OV-woord bijkomen.
|
||||
# Gewicht 1 (zwak/dubbelzinnig: los "bus", "kaartje", "rails"): moet met een ander
|
||||
# OV-woord combineren. Weert zo "VW-bus"/"schoolbus", "weer op de rails" en
|
||||
# 112-berichten met "bussen rijden om" in de lead, zonder eenduidige
|
||||
# bushalte/overweg/NS/trein-berichten te raken.
|
||||
# MATCH_FILTER_BLACKLIST: gematchte woorden die tóch geen OV-waarde hebben (o.a.
|
||||
# straatnamen rond stations — "de Stationsstraat afsluiten voor een festival"
|
||||
# is geen OV-nieuws). Zwartgelijste woorden leveren geen score op, maar
|
||||
# blokkeren de familie niet: een écht "station"-woord ernaast scoort gewoon.
|
||||
MATCH_FILTER_FAMILIES: list[tuple[str, int]] = [
|
||||
# sterk (3)
|
||||
(r"\b(?:ns|arriva|qbuzz|connexxion|cxx|transdev|keolis|ret|gvb|ebs|htm|syntus|breng|u-ov|allgo|flixbus|blauwnet|r-net|valleilijn|waterbus|eurostar|thalys|randstadrail|gvu|ov)\b", 3), # vervoerders
|
||||
(r"\b(?:spoorweg|spoorbaan|spoorlijn|spoorverkeer|spoorwegovergang|spoorstoring|perron|perrons|overweg|overwegen|seinstoring|wisselstoring|sein|ontsporing|ontspoord|spoorstaking)\b", 3), # spoorinfra
|
||||
(r"\bstation\w*\b", 3), # station(s)
|
||||
(r"\b(?:bus|tram|metro)(?:halte|haltes|lijn|lijnen|station|stations|verkeer|net|staking)\b", 3), # voertuig-samenstelling
|
||||
(r"\btrein\w*\b", 3), # trein(verkeer/staking/ongeluk/...)
|
||||
# medium (2)
|
||||
(r"\btram\b", 2),
|
||||
(r"\bmetro\b", 2),
|
||||
(r"\b(?:openbaar vervoer|dienstregeling|inchecken|uitchecken|lightrail|pendelbus|shuttlebus)\b", 2),
|
||||
(r"\b(?:ov-chipkaart|chipkaart|ovpay|ov-pay|reisproduct|studentenreisproduct|vervoersbewijs|vervoerbewijs|opwaarderen)\b", 2),
|
||||
(r"\b(?:ov-staking|conducteursstaking)\b", 2),
|
||||
(r"\b(?:pont|ponten)\b", 2), # waterbus/veerdienst
|
||||
# zwak (1) — dubbelzinnig, moet combineren
|
||||
(r"\bbus(?:sen)?\b", 1),
|
||||
(r"\bkaart(?:je|jes)\b", 1),
|
||||
(r"\brails\b", 1),
|
||||
]
|
||||
|
||||
# Drempel voor doorlaten (score = 2×titel + 1×samenvatting).
|
||||
MATCH_FILTER_THRESHOLD = 3
|
||||
|
||||
# Woorden die een familie wél matcht maar die geen OV-waarde hebben. Een hit dat
|
||||
# hierin staat telt niet mee voor de score (en blokkeert de familie niet). Vorm:
|
||||
# exact woord (lowercase), zoals door de familie-regex wordt gematcht.
|
||||
MATCH_FILTER_BLACKLIST: frozenset[str] = frozenset({
|
||||
# Straatnamen rond stations (genitief-s-vorm van "\bstation\w*\b"): een straat
|
||||
# die toevallig bij een station ligt maakt een bericht nog geen OV-bericht.
|
||||
"stationsstraat", "stationsweg", "stationslaan", "stationsplein",
|
||||
"stationssingel", "stationskade", "stationsdreef", "stationshof",
|
||||
"stationspad", "stationsboulevard", "stationsgracht", "stationsdijk",
|
||||
"stationssteeg", "stationswal", "stationsbuurt", "stationswijk", "stationspark",
|
||||
# Idem voor "\btrein\w*\b".
|
||||
"treinstraat", "treinweg",
|
||||
})
|
||||
|
||||
_compiled_match_families: list[tuple[re.Pattern, int]] = [
|
||||
(re.compile(pat, re.IGNORECASE), w) for pat, w in MATCH_FILTER_FAMILIES
|
||||
]
|
||||
|
||||
|
||||
class Config(BaseProxyConfig):
|
||||
|
|
@ -69,10 +113,12 @@ class Config(BaseProxyConfig):
|
|||
helper.copy("spam_sleep")
|
||||
helper.copy("command_prefix")
|
||||
helper.copy("notification_template")
|
||||
helper.copy("allow_filter")
|
||||
helper.copy("exclude_paywalled")
|
||||
helper.copy("match_filter")
|
||||
helper.copy("title_dedup_days")
|
||||
helper.copy("run_log_file")
|
||||
helper.copy("whatsapp_api")
|
||||
helper.copy("whatsapp_session")
|
||||
helper.copy("whatsapp_chat_id")
|
||||
helper.copy("admins")
|
||||
|
||||
|
||||
|
|
@ -95,6 +141,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
|
||||
|
|
@ -110,6 +157,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)
|
||||
|
|
@ -149,6 +205,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()
|
||||
|
|
@ -162,46 +219,54 @@ class RSSBot(Plugin):
|
|||
self.log.exception("Fatal error while polling feeds")
|
||||
|
||||
@staticmethod
|
||||
def _matches_in(text: str, match_filter: str) -> int:
|
||||
"""Aantal aaneengesloten match-clusters in text (0 als geen match).
|
||||
Aaneengesloten matches (bv 'NS-trein', 'openbaar vervoer') tellen als 1."""
|
||||
spans = sorted((m.start(), m.end()) for m in re.finditer(match_filter, text))
|
||||
if not spans:
|
||||
return 0
|
||||
clusters, prev_end = 1, spans[0][1]
|
||||
for s, e in spans[1:]:
|
||||
if s - prev_end <= 1:
|
||||
prev_end = max(prev_end, e)
|
||||
else:
|
||||
clusters += 1
|
||||
prev_end = e
|
||||
return clusters
|
||||
def _fam_score(text: str) -> int:
|
||||
"""Som van gewichten over de OV-families die in text voorkomen (1× per familie).
|
||||
|
||||
Gematchte woorden uit MATCH_FILTER_BLACKLIST (o.a. 'stationsstraat')
|
||||
tellen niet mee en blokkeren de familie niet: een écht OV-woord in
|
||||
dezelfde tekst scoort gewoon verder."""
|
||||
total = 0
|
||||
for family, weight in _compiled_match_families:
|
||||
for m in family.finditer(text):
|
||||
if m.group(0).lower() in MATCH_FILTER_BLACKLIST:
|
||||
continue
|
||||
total += weight
|
||||
break
|
||||
return total
|
||||
|
||||
@staticmethod
|
||||
def _filter_score(title: str, summary: str, match_filter: str) -> int:
|
||||
"""Score = 2× hits in titel + 1× hits in summary."""
|
||||
return 2 * RSSBot._matches_in(title, match_filter) + RSSBot._matches_in(summary, match_filter)
|
||||
def _filter_passes(title: str, summary: str) -> bool:
|
||||
"""Gewogen familiescore: doorlaten bij 2×(titelscore) + 1×(samenvattingscore) >= drempel.
|
||||
|
||||
Sterke families (gewicht 3) halen de drempel al met één woord in titel óf
|
||||
samenvatting. Medium (2) in de titel ook; in de samenvatting moet een tweede
|
||||
OV-woord bijkomen. Zwakke woorden (losse 'bus', 'kaartje', 'rails') moeten
|
||||
combineren met een ander OV-woord. Weert oa 'VW-bus', 'schoolbus',
|
||||
'bussen rijden om' in een woningbrand-lead en figuratief 'weer op de rails'.
|
||||
Bewust doorgelaten: krant 'De Metro'/café 'De Tram' in de titel (medium)."""
|
||||
return 2 * RSSBot._fam_score(title) + RSSBot._fam_score(summary) >= MATCH_FILTER_THRESHOLD
|
||||
|
||||
@staticmethod
|
||||
def _filter_passes(title: str, summary: str, match_filter: str) -> bool:
|
||||
"""Regel C: doorlaten bij (titel-match >= 1) of (totaalscore >= 3).
|
||||
|
||||
Een OV-trefwoord in de titel is een sterke intentieverklaring (score 2
|
||||
volstaat). Treffers alleen in de samenvatting moeten dubbel bevestigd
|
||||
worden (>= 3): zo houdt 'bussen rijden om' in de lead van een
|
||||
woningbrand-bericht het artikel niet meer binnen."""
|
||||
if RSSBot._matches_in(title, match_filter) >= 1:
|
||||
return True
|
||||
return RSSBot._filter_score(title, summary, match_filter) >= 3
|
||||
def _filter_summary() -> str:
|
||||
"""Mensvriendelijke weergave van het vaste gewogen match-filter."""
|
||||
return (
|
||||
"**OV-matchfilter (vast in code, gewogen families):**\n"
|
||||
"- Sterk (3): vervoerders (NS, Arriva, Qbuzz, ...), spoorinfra (perron, overweg, sein), "
|
||||
"station(s), bushalte/buslijn/treinstel, trein\n"
|
||||
"- Medium (2): tram, metro, openbaar vervoer/dienstregeling/inchecken, "
|
||||
"ov-chipkaart/ovpay/reisproduct, ov-staking, pont\n"
|
||||
"- Zwak (1): losse 'bus', 'kaartje', 'rails' — moet combineren met een ander OV-woord\n\n"
|
||||
"Uitgesloten (blacklist): woorden die wél matchen maar geen OV-waarde hebben, "
|
||||
"o.a. straatnamen rond stations ('stationsstraat', 'stationsweg', ...)\n\n"
|
||||
"Doorlaten: `2×(titel-score) + 1×(samenvatting-score) ≥ 3`"
|
||||
)
|
||||
|
||||
async def _send(
|
||||
self, feed: Feed, entry: Entry, sub: Subscription, *, check_filter: bool = True
|
||||
) -> EventID | None:
|
||||
if check_filter:
|
||||
match_filter = self.config["match_filter"]
|
||||
if match_filter:
|
||||
summary = html.unescape(re.sub(r"<[^>]+>", "", entry.summary))
|
||||
if not self._filter_passes(entry.title, summary, match_filter):
|
||||
if not self._filter_passes(entry.title, summary):
|
||||
return None
|
||||
|
||||
message = sub.notification_template.safe_substitute(
|
||||
|
|
@ -222,20 +287,76 @@ class RSSBot(Plugin):
|
|||
self.log.warning(f"Failed to send {entry.id} of {feed.id} to {sub.room_id}: {e}")
|
||||
return None
|
||||
|
||||
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
|
||||
if await self.dbm.is_whatsapp_posted(link):
|
||||
self.log.info(f"{entry.id} already posted to WhatsApp before, skipping")
|
||||
return False
|
||||
session = self.config.get("whatsapp_session", "EdGpt")
|
||||
base = api.rstrip("/")
|
||||
# Dedup: sla over als de URL al in de doelgroep staat. Eerst de lokale
|
||||
# tabel (eigen posts; daarna de globale search (vreemde posts), gefilterd
|
||||
# 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", {})
|
||||
if (
|
||||
msg_data.get("id", {}).get("remote") == chat_id
|
||||
and link in msg_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"))
|
||||
if ok:
|
||||
try:
|
||||
await self.dbm.mark_whatsapp_posted(link)
|
||||
except Exception as e:
|
||||
self.log.warning(f"Failed to record WhatsApp post {entry.id}: {e}")
|
||||
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
|
||||
|
||||
async def _broadcast(
|
||||
self, feed: Feed, entry: Entry, subscriptions: list[Subscription]
|
||||
) -> tuple[int, int, int]:
|
||||
"""Send entry to all subs. Returns (placed, filtered, failed).
|
||||
|
||||
placed = 1 when at least one room received the message
|
||||
placed = 1 when the entry was posted to at least one channel (Matrix room or newly posted WhatsApp link)
|
||||
filtered = 1 when the global match filter rejected the entry
|
||||
failed = number of subscriptions where sending failed
|
||||
"""
|
||||
self.log.debug(f"Broadcasting {entry.id} of {feed.id}")
|
||||
match_filter = self.config["match_filter"]
|
||||
if match_filter:
|
||||
summary = html.unescape(re.sub(r"<[^>]+>", "", entry.summary))
|
||||
if not self._filter_passes(entry.title, summary, match_filter):
|
||||
if not self._filter_passes(entry.title, summary):
|
||||
return 0, 1, 0
|
||||
spam_sleep = self.config["spam_sleep"]
|
||||
if spam_sleep >= 0:
|
||||
|
|
@ -248,15 +369,21 @@ class RSSBot(Plugin):
|
|||
*(self._send(feed, entry, sub, check_filter=False) for sub in subscriptions)
|
||||
)
|
||||
sent = sum(1 for result in results if result is not None)
|
||||
return (1 if sent else 0, 0, len(results) - sent)
|
||||
whatsapp_ok = await self._send_whatsapp(feed, entry)
|
||||
return (1 if (sent or whatsapp_ok) else 0, 0, len(results) - sent)
|
||||
|
||||
async def _poll_once(self) -> None:
|
||||
subs = await self.dbm.get_feeds()
|
||||
if not subs:
|
||||
self._log_run_summary(0, 0, 0, 0, 0, 0, 0.0)
|
||||
self._log_run_summary(0, 0, 0, 0, 0, 0, 0, 0.0)
|
||||
return
|
||||
now = int(time())
|
||||
start = monotonic()
|
||||
title_dedup_days = int(self.config.get("title_dedup_days", 30))
|
||||
posted_titles: set[str] = set()
|
||||
if title_dedup_days > 0:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=title_dedup_days)
|
||||
posted_titles = await self.dbm.get_recent_posted_titles(cutoff)
|
||||
tasks = [self.try_parse_feed(feed=feed) for feed in subs if feed.next_retry < now]
|
||||
feed: Feed
|
||||
entries: list[Entry] | None
|
||||
|
|
@ -264,6 +391,7 @@ class RSSBot(Plugin):
|
|||
evaluated_total = 0
|
||||
placed_total = 0
|
||||
filtered_total = 0
|
||||
deduped_total = 0
|
||||
failed_total = 0
|
||||
unchanged_total = 0
|
||||
for res in asyncio.as_completed(tasks):
|
||||
|
|
@ -304,18 +432,36 @@ class RSSBot(Plugin):
|
|||
new_entry_list.sort(key=lambda entry: (entry.date, entry.id))
|
||||
await self.dbm.add_entries(new_entry_list)
|
||||
for entry in new_entry_list:
|
||||
norm = normalize_title(entry.title)
|
||||
if title_dedup_days > 0 and norm and norm in posted_titles:
|
||||
deduped_total += 1
|
||||
self.log.info(
|
||||
f"{entry.id} of feed {feed.id} title already posted ({norm[:60]}), skipping"
|
||||
)
|
||||
continue
|
||||
placed, filtered, failed = await self._broadcast(
|
||||
feed, entry, feed.subscriptions
|
||||
)
|
||||
placed_total += placed
|
||||
filtered_total += filtered
|
||||
failed_total += failed
|
||||
if placed and norm:
|
||||
# In-memory set: vangt ook duplicaten binnen dezelfde poll-run
|
||||
# (twee sites van dezelfde uitgever in één run).
|
||||
posted_titles.add(norm)
|
||||
try:
|
||||
await self.dbm.mark_entry_posted(feed.id, entry.id)
|
||||
except Exception:
|
||||
self.log.warning(
|
||||
f"Failed to mark {entry.id} of feed {feed.id} as posted"
|
||||
)
|
||||
duration = monotonic() - start
|
||||
self._log_run_summary(
|
||||
len(tasks),
|
||||
evaluated_total,
|
||||
placed_total,
|
||||
filtered_total,
|
||||
deduped_total,
|
||||
failed_total,
|
||||
unchanged_total,
|
||||
duration,
|
||||
|
|
@ -327,13 +473,14 @@ class RSSBot(Plugin):
|
|||
evaluated: int,
|
||||
selected: int,
|
||||
filtered: int,
|
||||
deduped: int,
|
||||
failed: int,
|
||||
unchanged: int,
|
||||
duration: float,
|
||||
) -> None:
|
||||
line = (
|
||||
f"run complete feeds={feeds} evaluated={evaluated} selected={selected} "
|
||||
f"filtered={filtered} failed={failed} unchanged={unchanged} "
|
||||
f"filtered={filtered} deduped={deduped} failed={failed} unchanged={unchanged} "
|
||||
f"duration={duration:.2f}s"
|
||||
)
|
||||
self.log.info(f"Finished polling {feeds} feeds in {duration:.2f} seconds: {line}")
|
||||
|
|
@ -583,56 +730,16 @@ class RSSBot(Plugin):
|
|||
await evt.reply(f"Subscribed to {feed_info}")
|
||||
|
||||
@rss.subcommand(
|
||||
"set-filter", aliases=("f",), help="Set the global match filter: !rss set-filter <regex> | !rss set-filter clear"
|
||||
)
|
||||
@command.argument("match_filter", "match filter", pass_raw=True, required=False)
|
||||
async def set_filter(self, evt: MessageEvent, match_filter: str = "") -> None:
|
||||
if evt.sender in self.config["admins"]:
|
||||
pass
|
||||
elif not self.config["allow_filter"]:
|
||||
await evt.reply("This bot does not allow non-admin users to set the match filter")
|
||||
return
|
||||
elif not await self.can_manage(evt):
|
||||
return
|
||||
if not match_filter:
|
||||
# Lege aanroep: nooit het filter wissen. Toon usage + huidige filter.
|
||||
f = self.config["match_filter"]
|
||||
if f:
|
||||
await evt.reply(
|
||||
f"Usage: `!rss set-filter <regex>` or `!rss set-filter clear`\n\n"
|
||||
f"**Current filter:**\n```\n{f}\n```"
|
||||
)
|
||||
else:
|
||||
await evt.reply(
|
||||
"Usage: `!rss set-filter <regex>` or `!rss set-filter clear`\n\n"
|
||||
"No filter is currently set (all posts are sent)"
|
||||
)
|
||||
return
|
||||
if match_filter.strip().lower() in ("clear", "none", "off", "remove"):
|
||||
self.config["match_filter"] = ""
|
||||
self.config.save()
|
||||
await evt.reply("Global filter removed (all posts will be sent)")
|
||||
return
|
||||
try:
|
||||
re.compile(match_filter)
|
||||
except re.error:
|
||||
await evt.reply("Filter is not a valid regular expression")
|
||||
return
|
||||
self.config["match_filter"] = match_filter
|
||||
self.config.save()
|
||||
await evt.reply(
|
||||
f"Global filter updated: only posts matching `{match_filter}` will be sent"
|
||||
"set-filter", aliases=("f",), help="Show the fixed global OV match filter."
|
||||
)
|
||||
async def set_filter(self, evt: MessageEvent) -> None:
|
||||
await evt.reply(self._filter_summary())
|
||||
|
||||
@rss.subcommand(
|
||||
"filter", aliases=("show-filter", "get-filter"), help="Show the current global match filter."
|
||||
)
|
||||
async def command_show_filter(self, evt: MessageEvent) -> None:
|
||||
f = self.config["match_filter"]
|
||||
if f:
|
||||
await evt.reply(f"**Current global filter:**\n```\n{f}\n```")
|
||||
else:
|
||||
await evt.reply("No global filter is set (all posts are sent)")
|
||||
await evt.reply(self._filter_summary())
|
||||
|
||||
@rss.subcommand(
|
||||
"unsubscribe", aliases=("u", "unsub"), help="Unsubscribe this room from a feed."
|
||||
|
|
@ -723,15 +830,10 @@ class RSSBot(Plugin):
|
|||
)
|
||||
async def command_subscriptions(self, evt: MessageEvent) -> None:
|
||||
subscriptions = await self.dbm.get_feeds_by_room(evt.room_id)
|
||||
match_filter = self.config["match_filter"]
|
||||
if len(subscriptions) == 0:
|
||||
await evt.reply("There are no RSS subscriptions in this room")
|
||||
return
|
||||
filter_line = (
|
||||
f"**Global filter:** `{match_filter}`"
|
||||
if match_filter
|
||||
else "**Global filter:** none (all posts are sent)"
|
||||
)
|
||||
filter_line = self._filter_summary().replace("\n", " \n")
|
||||
await evt.reply(
|
||||
"**Subscriptions in this room:**\n\n"
|
||||
+ "\n".join(
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from string import Template
|
||||
|
||||
|
|
@ -85,6 +87,19 @@ date_fmt = "%Y-%m-%d %H:%M:%S"
|
|||
date_fmt_microseconds = "%Y-%m-%d %H:%M:%S.%f"
|
||||
|
||||
|
||||
def normalize_title(title: str) -> str:
|
||||
"""Genormaliseerde titel-sleutel voor cross-feed duplicate-detectie.
|
||||
|
||||
Stript HTML, lowercaset, vouwt whitespace samen en verwijdert rand-interpunctie,
|
||||
zodat hetzelfde artikel dat door één uitgever onder een andere URL/feed wordt
|
||||
gepubliceerd (vrijwel) identieke titels matcht. Bewust exacte matching:
|
||||
terugkerende koppen ("treinstoring", "staking") met écht andere titels
|
||||
mogen niet als duplicaat worden weggefilterd."""
|
||||
text = html.unescape(re.sub(r"<[^>]+>", "", title or ""))
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
return text.strip(" \t\n.,;:!?…-–—").lower()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Entry:
|
||||
feed_id: int
|
||||
|
|
@ -151,6 +166,21 @@ class DBManager:
|
|||
for row in existing:
|
||||
entries.pop(row["id"], None)
|
||||
|
||||
async def get_recent_posted_titles(self, since: datetime) -> set[str]:
|
||||
"""Genormaliseerde titels van entries die sinds `since` daadwerkelijk zijn
|
||||
gebroadcast (posted=true). Dit is de bron voor cross-feed titel-dedup."""
|
||||
rows = await self.db.fetch(
|
||||
"SELECT title FROM entry WHERE posted AND date >= $1", since
|
||||
)
|
||||
return {normalize_title(row["title"]) for row in rows if row["title"]}
|
||||
|
||||
async def mark_entry_posted(self, feed_id: int, entry_id: str) -> None:
|
||||
await self.db.execute(
|
||||
"UPDATE entry SET posted = true WHERE feed_id = $1 AND id = $2",
|
||||
feed_id,
|
||||
entry_id,
|
||||
)
|
||||
|
||||
async def add_entries(self, entries: list[Entry], override_feed_id: int | None = None) -> None:
|
||||
if not entries:
|
||||
return
|
||||
|
|
@ -232,3 +262,10 @@ class DBManager:
|
|||
async def set_send_notice(self, feed_id: int, room_id: RoomID, send_notice: bool) -> None:
|
||||
q = "UPDATE subscription SET send_notice=$3 WHERE feed_id=$1 AND room_id=$2"
|
||||
await self.db.execute(q, feed_id, room_id, send_notice)
|
||||
|
||||
async def is_whatsapp_posted(self, link: str) -> bool:
|
||||
row = await self.db.fetchrow("SELECT 1 FROM whatsapp_posted WHERE link = $1", link)
|
||||
return row is not None
|
||||
|
||||
async def mark_whatsapp_posted(self, link: str) -> None:
|
||||
await self.db.execute("INSERT INTO whatsapp_posted (link) VALUES ($1) ON CONFLICT DO NOTHING", link)
|
||||
|
|
|
|||
|
|
@ -105,3 +105,25 @@ async def upgrade_v8(conn: Connection) -> None:
|
|||
async def upgrade_v9(conn: Connection) -> None:
|
||||
await conn.execute("ALTER TABLE feed ADD COLUMN etag TEXT DEFAULT ''")
|
||||
await conn.execute("ALTER TABLE feed ADD COLUMN last_modified TEXT DEFAULT ''")
|
||||
|
||||
|
||||
@upgrade_table.register(description="Add WhatsApp posted-link dedup table")
|
||||
async def upgrade_v10(conn: Connection, scheme: Scheme) -> None:
|
||||
default = "now()" if scheme == Scheme.POSTGRES else "CURRENT_TIMESTAMP"
|
||||
await conn.execute(
|
||||
f"CREATE TABLE IF NOT EXISTS whatsapp_posted ("
|
||||
f"link TEXT PRIMARY KEY, "
|
||||
f"posted_at {'TIMESTAMP WITH TIME ZONE' if scheme == Scheme.POSTGRES else 'TIMESTAMP'} "
|
||||
f"NOT NULL DEFAULT {default}"
|
||||
f")"
|
||||
)
|
||||
|
||||
|
||||
@upgrade_table.register(description="Add posted flag to entries for cross-feed title dedup")
|
||||
async def upgrade_v11(conn: Connection, scheme: Scheme) -> None:
|
||||
# posted = deze entry is daadwerkelijk gebroadcast (Matrix en/of WhatsApp).
|
||||
# Nieuwe entries die alleen 'gezien' zijn (bv. feed-historis bij subscribe) en
|
||||
# filter-afgewezen entries blijven false en blokkeren geen latere titel-match.
|
||||
await conn.execute("ALTER TABLE entry ADD COLUMN posted BOOLEAN NOT NULL DEFAULT false")
|
||||
# Venster-query voor de titel-dedup: recent gepostede titels ophalen.
|
||||
await conn.execute("CREATE INDEX entry_date_idx ON entry (date)")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
id: nl.eddydevink.rss
|
||||
version: 1.5.4
|
||||
version: 1.8.2
|
||||
modules:
|
||||
- buzzrss
|
||||
main_class: RSSBot
|
||||
|
|
|
|||
Loading…
Reference in a new issue