Compare commits

...
Author SHA1 Message Date
Eddy de Vink
f4ea969f33 v1.8.2: blacklist straatnamen rond stations in OV-matchfilter
'de Stationsstraat afsluiten voor een festival' is geen OV-nieuws, maar
scored wel (familie \bstation\w*\b, gewicht 3; via `stations`+suffix
dingt `station\w*\b` op 'stationsstraat'). Regex-uitbreiding faalt op de
dubbele s (stations+straat): elke naamvorm vraagt opnieuw regex-herrie.

Daarom MATCH_FILTER_BLACKLIST: exact gematchte woorden die geen OV-waarde
hebben (straatnamen rond stations: stationsstraat/weg/laan/plein/... plus
treinstraat/treinweg) tellen niet meer mee. De genitief-s-vorm dekt zowel
'Stationsstraat' als 'Stationsweg' etc. Zwartgelijste woorden blokkeren de
familie niet: een echt station-/trein-woord ernaast scoort gewoon.
2026-09-10 08:42:33 +02:00
Eddy de Vink
c13ec81379 v1.8.1: idempotente start() — guard voorkomt dubbele poll-loop bij maubot double-start 2026-09-09 23:46:22 +02:00
Eddy de Vink
6264e73441 v1.8.0: titel-dedup over feeds (zelfde artikel, andere url) 2026-09-09 23:39:15 +02:00
Eddy de Vink
d7ba8a06de v1.7.0: gewogen OV-families-filter vervangt match_filter-regex
Filter is nu vast in code (MATCH_FILTER_FAMILIES + MATCH_FILTER_THRESHOLD):
doorlaten als 2x(titelfamiliescore) + 1x(samenvattingscore) >= 3.
Sterk(3): vervoerders, spoorinfra, station, voertuig-samenstellingen, trein.
Medium(2): tram, metro, dienst, vervoersbewijs/staking/pont.
Zwak(1): losse bus/kaartje/rails - moet combineren met ander OV-woord.
Weert 'VW-bus'/'schoolbus', woningbrand-leads met 'bussen rijden om' en
figuratief 'op de rails', zonder bushalte/overweg/NS/trein-berichten te raken.
Gewogen per familie ipv per cluster dicht ook het 'bus x3 in lead'-gat.
!rss set-filter en !rss filter zijn read-only geworden (tonen het vaste filter);
config-keys match_filter/allow_filter verwijderd uit do_update.
V1.7.0 gedeployed op nextcloud (instance rss, enabled+started, run-log actief).
2026-09-08 20:50:52 +02:00
8 changed files with 452 additions and 110 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
__pycache__/
*.pyc

170
WHATSAPP_DOORSTUREN_PLAN.md Normal file
View 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.

View file

@ -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). # Absoluut pad in de container aanraden: /data is de persistente maubot-mount (host: /matrix/maubot/data).
# Leeg = uitgeschakeld. # Leeg = uitgeschakeld.
run_log_file: /data/buzzrss-runs.log 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 articles (Telegraaf/NHD markeren dit met <premium>true</premium>)
exclude_paywalled: true exclude_paywalled: true
# Global match filter (whitelist): applies to ALL feeds in this instance. # Titel-dedup over feeds: skip een artikel waarvan de genormaliseerde titel al is gepost
# A post is ONLY sent when the regex matches the title OR the (HTML-stripped) summary. # binnen X dagen, ook als de feed/URL anders is (zelfde uitgever, meerdere sites).
# Set it live with `!rss set-filter <regex>`. Empty = send everything. # 0 = uit.
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" 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 # Users who can bypass room permission checks
admins: admins:
- "@user:example.com" - "@user:example.com"

View file

@ -16,7 +16,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from string import Template from string import Template
from time import mktime, monotonic, time from time import mktime, monotonic, time
import asyncio import asyncio
@ -43,22 +43,66 @@ from mautrix.types import (
from mautrix.util.async_db import UpgradeTable from mautrix.util.async_db import UpgradeTable
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper 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 from .migrations import upgrade_table
rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STATE) rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STATE)
DEFAULT_MATCH_FILTER = ( # Gewogen OV-trefwoord-families voor het match-filter. Elke tuple = (regex, gewicht).
r"(?i)" # Per familie wordt 1× geteld per tekst (niet per cluster/herhaling). Drempel (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" # doorlaten als 2×(titelscore) + 1×(samenvattingscore) >= MATCH_FILTER_THRESHOLD
r"|\b(?:bus|tram|metro)(?:halte|haltes|lijn|lijnen|station|stations|verkeer|net|staking|sen)?\b" # Gewicht 3 (sterk, onmiskenbaar OV): één woord in titel óf samenvatting volstaat.
r"|\btrein(?:en|verkeer|stel(?:len)?|staking|ongeluk|reizigers?|station|stations)?\b" # Gewicht 2 (medium): één woord in de titel volstaat; in de samenvatting moet er
r"|\b(?:spoorweg|spoorbaan|spoorlijn|spoorverkeer|spoorwegovergang|spoorstoring|perron|perrons|overweg|overwegen|seinstoring|wisselstoring|sein|ontsporing|ontspoord|spoorstaking)\b" # een tweede OV-woord bijkomen.
r"|\b(?:openbaar vervoer|dienstregeling|reizigers?|inchecken|uitchecken|pont|ponten|lightrail|pendelbus|shuttlebus)\b" # Gewicht 1 (zwak/dubbelzinnig: los "bus", "kaartje", "rails"): moet met een ander
r"|\b(?:ov-chipkaart|chipkaart|ovpay|ov-pay|reisproduct|studentenreisproduct|vervoersbewijs|vervoerbewijs|opwaarderen)\b" # OV-woord combineren. Weert zo "VW-bus"/"schoolbus", "weer op de rails" en
r"|\b(?:ov-staking|conducteursstaking)\b" # 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): class Config(BaseProxyConfig):
@ -69,10 +113,12 @@ class Config(BaseProxyConfig):
helper.copy("spam_sleep") helper.copy("spam_sleep")
helper.copy("command_prefix") helper.copy("command_prefix")
helper.copy("notification_template") helper.copy("notification_template")
helper.copy("allow_filter")
helper.copy("exclude_paywalled") helper.copy("exclude_paywalled")
helper.copy("match_filter") helper.copy("title_dedup_days")
helper.copy("run_log_file") helper.copy("run_log_file")
helper.copy("whatsapp_api")
helper.copy("whatsapp_session")
helper.copy("whatsapp_chat_id")
helper.copy("admins") helper.copy("admins")
@ -95,6 +141,7 @@ class RSSBot(Plugin):
dbm: DBManager dbm: DBManager
exclude_paywalled: bool = False exclude_paywalled: bool = False
poll_task: asyncio.Future poll_task: asyncio.Future
_poll_started: bool = False
poll_sema: asyncio.Semaphore | None poll_sema: asyncio.Semaphore | None
_run_logger: logging.Logger | None = None _run_logger: logging.Logger | None = None
_run_handler: logging.FileHandler | None = None _run_handler: logging.FileHandler | None = None
@ -110,6 +157,15 @@ class RSSBot(Plugin):
return upgrade_table return upgrade_table
async def start(self) -> None: 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() await super().start()
self.on_external_config_update() self.on_external_config_update()
self.dbm = DBManager(self.database) self.dbm = DBManager(self.database)
@ -149,6 +205,7 @@ class RSSBot(Plugin):
async def stop(self) -> None: async def stop(self) -> None:
await super().stop() await super().stop()
self._poll_started = False
self.poll_task.cancel() self.poll_task.cancel()
if self._run_handler: if self._run_handler:
self._run_handler.close() self._run_handler.close()
@ -162,47 +219,55 @@ class RSSBot(Plugin):
self.log.exception("Fatal error while polling feeds") self.log.exception("Fatal error while polling feeds")
@staticmethod @staticmethod
def _matches_in(text: str, match_filter: str) -> int: def _fam_score(text: str) -> int:
"""Aantal aaneengesloten match-clusters in text (0 als geen match). """Som van gewichten over de OV-families die in text voorkomen (1× per familie).
Aaneengesloten matches (bv 'NS-trein', 'openbaar vervoer') tellen als 1."""
spans = sorted((m.start(), m.end()) for m in re.finditer(match_filter, text)) Gematchte woorden uit MATCH_FILTER_BLACKLIST (o.a. 'stationsstraat')
if not spans: tellen niet mee en blokkeren de familie niet: een écht OV-woord in
return 0 dezelfde tekst scoort gewoon verder."""
clusters, prev_end = 1, spans[0][1] total = 0
for s, e in spans[1:]: for family, weight in _compiled_match_families:
if s - prev_end <= 1: for m in family.finditer(text):
prev_end = max(prev_end, e) if m.group(0).lower() in MATCH_FILTER_BLACKLIST:
else: continue
clusters += 1 total += weight
prev_end = e break
return clusters return total
@staticmethod @staticmethod
def _filter_score(title: str, summary: str, match_filter: str) -> int: def _filter_passes(title: str, summary: str) -> bool:
"""Score = 2× hits in titel + 1× hits in summary.""" """Gewogen familiescore: doorlaten bij 2×(titelscore) + 1×(samenvattingscore) >= drempel.
return 2 * RSSBot._matches_in(title, match_filter) + RSSBot._matches_in(summary, match_filter)
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 @staticmethod
def _filter_passes(title: str, summary: str, match_filter: str) -> bool: def _filter_summary() -> str:
"""Regel C: doorlaten bij (titel-match >= 1) of (totaalscore >= 3). """Mensvriendelijke weergave van het vaste gewogen match-filter."""
return (
Een OV-trefwoord in de titel is een sterke intentieverklaring (score 2 "**OV-matchfilter (vast in code, gewogen families):**\n"
volstaat). Treffers alleen in de samenvatting moeten dubbel bevestigd "- Sterk (3): vervoerders (NS, Arriva, Qbuzz, ...), spoorinfra (perron, overweg, sein), "
worden (>= 3): zo houdt 'bussen rijden om' in de lead van een "station(s), bushalte/buslijn/treinstel, trein\n"
woningbrand-bericht het artikel niet meer binnen.""" "- Medium (2): tram, metro, openbaar vervoer/dienstregeling/inchecken, "
if RSSBot._matches_in(title, match_filter) >= 1: "ov-chipkaart/ovpay/reisproduct, ov-staking, pont\n"
return True "- Zwak (1): losse 'bus', 'kaartje', 'rails' — moet combineren met een ander OV-woord\n\n"
return RSSBot._filter_score(title, summary, match_filter) >= 3 "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( async def _send(
self, feed: Feed, entry: Entry, sub: Subscription, *, check_filter: bool = True self, feed: Feed, entry: Entry, sub: Subscription, *, check_filter: bool = True
) -> EventID | None: ) -> EventID | None:
if check_filter: if check_filter:
match_filter = self.config["match_filter"] summary = html.unescape(re.sub(r"<[^>]+>", "", entry.summary))
if match_filter: if not self._filter_passes(entry.title, summary):
summary = html.unescape(re.sub(r"<[^>]+>", "", entry.summary)) return None
if not self._filter_passes(entry.title, summary, match_filter):
return None
message = sub.notification_template.safe_substitute( message = sub.notification_template.safe_substitute(
{ {
@ -222,21 +287,77 @@ class RSSBot(Plugin):
self.log.warning(f"Failed to send {entry.id} of {feed.id} to {sub.room_id}: {e}") self.log.warning(f"Failed to send {entry.id} of {feed.id} to {sub.room_id}: {e}")
return None 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( async def _broadcast(
self, feed: Feed, entry: Entry, subscriptions: list[Subscription] self, feed: Feed, entry: Entry, subscriptions: list[Subscription]
) -> tuple[int, int, int]: ) -> tuple[int, int, int]:
"""Send entry to all subs. Returns (placed, filtered, failed). """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 filtered = 1 when the global match filter rejected the entry
failed = number of subscriptions where sending failed failed = number of subscriptions where sending failed
""" """
self.log.debug(f"Broadcasting {entry.id} of {feed.id}") self.log.debug(f"Broadcasting {entry.id} of {feed.id}")
match_filter = self.config["match_filter"] summary = html.unescape(re.sub(r"<[^>]+>", "", entry.summary))
if match_filter: if not self._filter_passes(entry.title, summary):
summary = html.unescape(re.sub(r"<[^>]+>", "", entry.summary)) return 0, 1, 0
if not self._filter_passes(entry.title, summary, match_filter):
return 0, 1, 0
spam_sleep = self.config["spam_sleep"] spam_sleep = self.config["spam_sleep"]
if spam_sleep >= 0: if spam_sleep >= 0:
results: list[EventID | None] = [] results: list[EventID | None] = []
@ -248,15 +369,21 @@ class RSSBot(Plugin):
*(self._send(feed, entry, sub, check_filter=False) for sub in subscriptions) *(self._send(feed, entry, sub, check_filter=False) for sub in subscriptions)
) )
sent = sum(1 for result in results if result is not None) 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: async def _poll_once(self) -> None:
subs = await self.dbm.get_feeds() subs = await self.dbm.get_feeds()
if not subs: 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 return
now = int(time()) now = int(time())
start = monotonic() 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] tasks = [self.try_parse_feed(feed=feed) for feed in subs if feed.next_retry < now]
feed: Feed feed: Feed
entries: list[Entry] | None entries: list[Entry] | None
@ -264,6 +391,7 @@ class RSSBot(Plugin):
evaluated_total = 0 evaluated_total = 0
placed_total = 0 placed_total = 0
filtered_total = 0 filtered_total = 0
deduped_total = 0
failed_total = 0 failed_total = 0
unchanged_total = 0 unchanged_total = 0
for res in asyncio.as_completed(tasks): 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)) new_entry_list.sort(key=lambda entry: (entry.date, entry.id))
await self.dbm.add_entries(new_entry_list) await self.dbm.add_entries(new_entry_list)
for entry in 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( placed, filtered, failed = await self._broadcast(
feed, entry, feed.subscriptions feed, entry, feed.subscriptions
) )
placed_total += placed placed_total += placed
filtered_total += filtered filtered_total += filtered
failed_total += failed 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 duration = monotonic() - start
self._log_run_summary( self._log_run_summary(
len(tasks), len(tasks),
evaluated_total, evaluated_total,
placed_total, placed_total,
filtered_total, filtered_total,
deduped_total,
failed_total, failed_total,
unchanged_total, unchanged_total,
duration, duration,
@ -327,13 +473,14 @@ class RSSBot(Plugin):
evaluated: int, evaluated: int,
selected: int, selected: int,
filtered: int, filtered: int,
deduped: int,
failed: int, failed: int,
unchanged: int, unchanged: int,
duration: float, duration: float,
) -> None: ) -> None:
line = ( line = (
f"run complete feeds={feeds} evaluated={evaluated} selected={selected} " 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" f"duration={duration:.2f}s"
) )
self.log.info(f"Finished polling {feeds} feeds in {duration:.2f} seconds: {line}") 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}") await evt.reply(f"Subscribed to {feed_info}")
@rss.subcommand( @rss.subcommand(
"set-filter", aliases=("f",), help="Set the global match filter: !rss set-filter <regex> | !rss set-filter clear" "set-filter", aliases=("f",), help="Show the fixed global OV match filter."
) )
@command.argument("match_filter", "match filter", pass_raw=True, required=False) async def set_filter(self, evt: MessageEvent) -> None:
async def set_filter(self, evt: MessageEvent, match_filter: str = "") -> None: await evt.reply(self._filter_summary())
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"
)
@rss.subcommand( @rss.subcommand(
"filter", aliases=("show-filter", "get-filter"), help="Show the current global match filter." "filter", aliases=("show-filter", "get-filter"), help="Show the current global match filter."
) )
async def command_show_filter(self, evt: MessageEvent) -> None: async def command_show_filter(self, evt: MessageEvent) -> None:
f = self.config["match_filter"] await evt.reply(self._filter_summary())
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)")
@rss.subcommand( @rss.subcommand(
"unsubscribe", aliases=("u", "unsub"), help="Unsubscribe this room from a feed." "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: async def command_subscriptions(self, evt: MessageEvent) -> None:
subscriptions = await self.dbm.get_feeds_by_room(evt.room_id) subscriptions = await self.dbm.get_feeds_by_room(evt.room_id)
match_filter = self.config["match_filter"]
if len(subscriptions) == 0: if len(subscriptions) == 0:
await evt.reply("There are no RSS subscriptions in this room") await evt.reply("There are no RSS subscriptions in this room")
return return
filter_line = ( filter_line = self._filter_summary().replace("\n", " \n")
f"**Global filter:** `{match_filter}`"
if match_filter
else "**Global filter:** none (all posts are sent)"
)
await evt.reply( await evt.reply(
"**Subscriptions in this room:**\n\n" "**Subscriptions in this room:**\n\n"
+ "\n".join( + "\n".join(

View file

@ -15,6 +15,8 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations from __future__ import annotations
import html
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from string import Template 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" 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 @dataclass
class Entry: class Entry:
feed_id: int feed_id: int
@ -151,6 +166,21 @@ class DBManager:
for row in existing: for row in existing:
entries.pop(row["id"], None) 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: async def add_entries(self, entries: list[Entry], override_feed_id: int | None = None) -> None:
if not entries: if not entries:
return return
@ -232,3 +262,10 @@ class DBManager:
async def set_send_notice(self, feed_id: int, room_id: RoomID, send_notice: bool) -> None: 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" 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) 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)

View file

@ -105,3 +105,25 @@ async def upgrade_v8(conn: Connection) -> None:
async def upgrade_v9(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 etag TEXT DEFAULT ''")
await conn.execute("ALTER TABLE feed ADD COLUMN last_modified 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)")

View file

@ -1,5 +1,5 @@
id: nl.eddydevink.rss id: nl.eddydevink.rss
version: 1.5.4 version: 1.8.2
modules: modules:
- buzzrss - buzzrss
main_class: RSSBot main_class: RSSBot