From 66b0588f5214f3b2aa14800e27c4bef28a2d9a59 Mon Sep 17 00:00:00 2001 From: Louis Simoneau Date: Fri, 10 Apr 2026 16:45:46 +1000 Subject: [PATCH] Rewrite Miniflux plugin to use requests, add filter and bookmark tools Drop the miniflux pip client in favour of requests (already in the container). Add update_feed_filters (keeplist/blocklist regex), toggle_bookmark, get_entry (full content), and category filtering. Remove the pip install step from Ansible. Co-Authored-By: Claude Opus 4.6 --- infra/ansible/playbook.yml | 1 + infra/hermes/plugins/miniflux/__init__.py | 24 +++++ infra/hermes/plugins/miniflux/plugin.yaml | 8 +- infra/hermes/plugins/miniflux/schemas.py | 82 +++++++++++++++ infra/hermes/plugins/miniflux/tools.py | 119 +++++++++++++++++----- 5 files changed, 209 insertions(+), 25 deletions(-) diff --git a/infra/ansible/playbook.yml b/infra/ansible/playbook.yml index 597cd16..ba3ac8c 100644 --- a/infra/ansible/playbook.yml +++ b/infra/ansible/playbook.yml @@ -399,6 +399,7 @@ chdir: /opt/hermes tags: hermes + # ── GoatCounter ───────────────────────────────────────────────────────── - name: Create goatcounter system user diff --git a/infra/hermes/plugins/miniflux/__init__.py b/infra/hermes/plugins/miniflux/__init__.py index c31b6df..5e937fc 100644 --- a/infra/hermes/plugins/miniflux/__init__.py +++ b/infra/hermes/plugins/miniflux/__init__.py @@ -14,3 +14,27 @@ def register(ctx): schema=schemas.GET_UNREAD_ENTRIES, handler=tools.get_unread_entries, ) + ctx.register_tool( + name="get_entry", + toolset="miniflux", + schema=schemas.GET_ENTRY, + handler=tools.get_entry, + ) + ctx.register_tool( + name="toggle_bookmark", + toolset="miniflux", + schema=schemas.TOGGLE_BOOKMARK, + handler=tools.toggle_bookmark, + ) + ctx.register_tool( + name="update_feed_filters", + toolset="miniflux", + schema=schemas.UPDATE_FEED_FILTERS, + handler=tools.update_feed_filters, + ) + ctx.register_tool( + name="mark_as_read", + toolset="miniflux", + schema=schemas.MARK_AS_READ, + handler=tools.mark_as_read, + ) diff --git a/infra/hermes/plugins/miniflux/plugin.yaml b/infra/hermes/plugins/miniflux/plugin.yaml index 3c957af..aa95650 100644 --- a/infra/hermes/plugins/miniflux/plugin.yaml +++ b/infra/hermes/plugins/miniflux/plugin.yaml @@ -1,6 +1,10 @@ name: miniflux -version: 1.0.0 -description: Read feeds and entries from the local Miniflux RSS reader +version: 2.0.0 +description: Read and manage feeds and entries from the local Miniflux RSS reader provides_tools: - list_feeds - get_unread_entries + - get_entry + - toggle_bookmark + - update_feed_filters + - mark_as_read diff --git a/infra/hermes/plugins/miniflux/schemas.py b/infra/hermes/plugins/miniflux/schemas.py index b82ce3d..dd6e66d 100644 --- a/infra/hermes/plugins/miniflux/schemas.py +++ b/infra/hermes/plugins/miniflux/schemas.py @@ -24,6 +24,10 @@ GET_UNREAD_ENTRIES = { "type": "integer", "description": "Filter to a specific feed. Omit for all feeds.", }, + "category_id": { + "type": "integer", + "description": "Filter to a specific category. Omit for all categories.", + }, "limit": { "type": "integer", "description": "Maximum number of entries to return. Defaults to 20.", @@ -32,3 +36,81 @@ GET_UNREAD_ENTRIES = { "required": [], }, } + +GET_ENTRY = { + "name": "get_entry", + "description": ( + "Get a single entry from Miniflux by ID, including its full content. " + "Use this to read an article's text." + ), + "parameters": { + "type": "object", + "properties": { + "entry_id": { + "type": "integer", + "description": "The entry ID to retrieve.", + }, + }, + "required": ["entry_id"], + }, +} + +TOGGLE_BOOKMARK = { + "name": "toggle_bookmark", + "description": "Toggle the bookmark/star status of a Miniflux entry.", + "parameters": { + "type": "object", + "properties": { + "entry_id": { + "type": "integer", + "description": "The entry ID to bookmark or unbookmark.", + }, + }, + "required": ["entry_id"], + }, +} + +UPDATE_FEED_FILTERS = { + "name": "update_feed_filters", + "description": ( + "Update the keep or block filter rules on a Miniflux feed. " + "Rules are case-insensitive regexes matched against entry titles and URLs. " + "keeplist_rules: only entries matching are kept. " + "blocklist_rules: entries matching are excluded. " + "Pass an empty string to clear a rule." + ), + "parameters": { + "type": "object", + "properties": { + "feed_id": { + "type": "integer", + "description": "The feed ID to update.", + }, + "keeplist_rules": { + "type": "string", + "description": "Regex pattern. Only matching entries are kept. Omit to leave unchanged.", + }, + "blocklist_rules": { + "type": "string", + "description": "Regex pattern. Matching entries are excluded. Omit to leave unchanged.", + }, + }, + "required": ["feed_id"], + }, +} + +MARK_AS_READ = { + "name": "mark_as_read", + "description": "Mark one or more Miniflux entries as read.", + "parameters": { + "type": "object", + "properties": { + "entry_ids": { + "type": "array", + "items": {"type": "integer"}, + "description": "List of entry IDs to mark as read.", + }, + }, + "required": ["entry_ids"], + }, +} diff --git a/infra/hermes/plugins/miniflux/tools.py b/infra/hermes/plugins/miniflux/tools.py index 4c17b8c..65a1153 100644 --- a/infra/hermes/plugins/miniflux/tools.py +++ b/infra/hermes/plugins/miniflux/tools.py @@ -1,39 +1,42 @@ import json -import urllib.request -import urllib.error from pathlib import Path +import requests + _PLUGIN_DIR = Path(__file__).parent with open(_PLUGIN_DIR / "config.json") as _f: _CONFIG = json.loads(_f.read()) -MINIFLUX_BASE = _CONFIG.get("base_url", "http://miniflux:8080") -MINIFLUX_KEY = _CONFIG.get("api_key", "") +_BASE = _CONFIG.get("base_url", "http://miniflux:8080").rstrip("/") +_HEADERS = {"X-Auth-Token": _CONFIG.get("api_key", "")} -def _request(path): - """Make an authenticated GET request to the Miniflux API.""" - url = f"{MINIFLUX_BASE}/v1{path}" - req = urllib.request.Request(url, headers={"X-Auth-Token": MINIFLUX_KEY}) - with urllib.request.urlopen(req, timeout=10) as resp: - return json.loads(resp.read()) +def _get(path, **params): + resp = requests.get(f"{_BASE}/v1{path}", headers=_HEADERS, params=params, timeout=10) + resp.raise_for_status() + return resp.json() + + +def _put(path, body): + resp = requests.put(f"{_BASE}/v1{path}", headers=_HEADERS, json=body, timeout=10) + resp.raise_for_status() + return resp def list_feeds(args: dict, **kwargs) -> str: try: - feeds = _request("/feeds") - counters = _request("/feeds/counters") - unreads = counters.get("reads", {}) # keyed by feed id - unread_map = counters.get("unreads", {}) + feeds = _get("/feeds") + counters = _get("/feeds/counters") + unreads = counters.get("unreads", {}) result = [] for f in feeds: - fid = str(f["id"]) result.append({ "id": f["id"], "title": f["title"], "site_url": f.get("site_url", ""), - "unread": unread_map.get(fid, 0), + "category": f.get("category", {}).get("title", ""), + "unread": unreads.get(str(f["id"]), 0), }) result.sort(key=lambda x: x["unread"], reverse=True) return json.dumps({"feeds": result, "total": len(result)}) @@ -43,15 +46,20 @@ def list_feeds(args: dict, **kwargs) -> str: def get_unread_entries(args: dict, **kwargs) -> str: try: - limit = args.get("limit", 20) - feed_id = args.get("feed_id") - - if feed_id: - path = f"/feeds/{feed_id}/entries?status=unread&limit={limit}&direction=desc&order=published_at" + params = { + "status": "unread", + "limit": args.get("limit", 20), + "direction": "desc", + "order": "published_at", + } + if args.get("feed_id"): + path = f"/feeds/{args['feed_id']}/entries" + elif args.get("category_id"): + path = f"/categories/{args['category_id']}/entries" else: - path = f"/entries?status=unread&limit={limit}&direction=desc&order=published_at" + path = "/entries" - data = _request(path) + data = _get(path, **params) entries = [] for e in data.get("entries", []): entries.append({ @@ -59,6 +67,7 @@ def get_unread_entries(args: dict, **kwargs) -> str: "title": e["title"], "url": e.get("url", ""), "feed": e.get("feed", {}).get("title", ""), + "category": e.get("feed", {}).get("category", {}).get("title", ""), "author": e.get("author", ""), "published_at": e.get("published_at", ""), "reading_time": e.get("reading_time", 0), @@ -69,3 +78,67 @@ def get_unread_entries(args: dict, **kwargs) -> str: }) except Exception as e: return json.dumps({"error": str(e)}) + + +def get_entry(args: dict, **kwargs) -> str: + try: + entry = _get(f"/entries/{args['entry_id']}") + return json.dumps({ + "id": entry["id"], + "title": entry["title"], + "url": entry.get("url", ""), + "author": entry.get("author", ""), + "feed": entry.get("feed", {}).get("title", ""), + "category": entry.get("feed", {}).get("category", {}).get("title", ""), + "published_at": entry.get("published_at", ""), + "reading_time": entry.get("reading_time", 0), + "content": entry.get("content", ""), + }) + except Exception as e: + return json.dumps({"error": str(e)}) + + +def toggle_bookmark(args: dict, **kwargs) -> str: + try: + _put(f"/entries/{args['entry_id']}/bookmark", {}) + return json.dumps({"ok": True, "entry_id": args["entry_id"]}) + except Exception as e: + return json.dumps({"error": str(e)}) + + +def update_feed_filters(args: dict, **kwargs) -> str: + try: + feed_id = args["feed_id"] + body = {} + if "keeplist_rules" in args: + body["keeplist_rules"] = args["keeplist_rules"] + if "blocklist_rules" in args: + body["blocklist_rules"] = args["blocklist_rules"] + if not body: + return json.dumps({"error": "Provide keeplist_rules and/or blocklist_rules"}) + resp = requests.put( + f"{_BASE}/v1/feeds/{feed_id}", + headers=_HEADERS, json=body, timeout=10, + ) + resp.raise_for_status() + feed = resp.json() + return json.dumps({ + "ok": True, + "feed_id": feed["id"], + "title": feed["title"], + "keeplist_rules": feed.get("keeplist_rules", ""), + "blocklist_rules": feed.get("blocklist_rules", ""), + }) + except Exception as e: + return json.dumps({"error": str(e)}) + + +def mark_as_read(args: dict, **kwargs) -> str: + try: + entry_ids = args.get("entry_ids", []) + if not entry_ids: + return json.dumps({"error": "No entry_ids provided"}) + _put("/entries", {"entry_ids": entry_ids, "status": "read"}) + return json.dumps({"ok": True, "marked_read": entry_ids}) + except Exception as e: + return json.dumps({"error": str(e)})