diff --git a/infra/ansible/playbook.yml b/infra/ansible/playbook.yml index 262cbed..2349bea 100644 --- a/infra/ansible/playbook.yml +++ b/infra/ansible/playbook.yml @@ -17,6 +17,7 @@ hermes_openrouter_api_key: "{{ lookup('env', 'HERMES_OPENROUTER_API_KEY') }}" hermes_telegram_bot_token: "{{ lookup('env', 'HERMES_TELEGRAM_BOT_TOKEN') }}" hermes_telegram_allowed_users: "{{ lookup('env', 'HERMES_TELEGRAM_ALLOWED_USERS') }}" + hermes_miniflux_api_key: "{{ lookup('env', 'HERMES_MINIFLUX_API_KEY') }}" tasks: @@ -238,6 +239,15 @@ enabled: true state: started + - name: Create shared Docker network + command: docker network create monotrope + register: docker_net + changed_when: docker_net.rc == 0 + failed_when: docker_net.rc != 0 and 'already exists' not in docker_net.stderr + tags: + - miniflux + - hermes + # ── Miniflux ──────────────────────────────────────────────────────────── - name: Create Miniflux directory @@ -344,6 +354,17 @@ notify: Restart Hermes tags: hermes + - name: Copy Hermes plugins + copy: + src: ../hermes/plugins/ + dest: /opt/hermes/plugins/ + owner: root + group: root + mode: '0640' + directory_mode: '0750' + notify: Restart Hermes + tags: hermes + - name: Write Hermes .env copy: dest: /opt/hermes/.env @@ -354,6 +375,7 @@ OPENROUTER_API_KEY={{ hermes_openrouter_api_key }} TELEGRAM_BOT_TOKEN={{ hermes_telegram_bot_token }} TELEGRAM_ALLOWED_USERS={{ hermes_telegram_allowed_users }} + MINIFLUX_API_KEY={{ hermes_miniflux_api_key }} no_log: true tags: hermes diff --git a/infra/hermes/docker-compose.yml b/infra/hermes/docker-compose.yml index 33c4abc..3936676 100644 --- a/infra/hermes/docker-compose.yml +++ b/infra/hermes/docker-compose.yml @@ -9,15 +9,24 @@ services: options: max-size: "10m" max-file: "3" + networks: + - monotrope volumes: - hermes_data:/opt/data - ./config.yaml:/opt/data/config.yaml:ro + - ./plugins:/opt/data/plugins:ro environment: OPENROUTER_API_KEY: "${OPENROUTER_API_KEY}" TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN}" TELEGRAM_ALLOWED_USERS: "${TELEGRAM_ALLOWED_USERS}" + MINIFLUX_API_KEY: "${MINIFLUX_API_KEY}" + MINIFLUX_BASE_URL: "http://miniflux:8080" env_file: - .env +networks: + monotrope: + external: true + volumes: hermes_data: diff --git a/infra/hermes/plugins/miniflux/__init__.py b/infra/hermes/plugins/miniflux/__init__.py new file mode 100644 index 0000000..c31b6df --- /dev/null +++ b/infra/hermes/plugins/miniflux/__init__.py @@ -0,0 +1,16 @@ +from . import schemas, tools + + +def register(ctx): + ctx.register_tool( + name="list_feeds", + toolset="miniflux", + schema=schemas.LIST_FEEDS, + handler=tools.list_feeds, + ) + ctx.register_tool( + name="get_unread_entries", + toolset="miniflux", + schema=schemas.GET_UNREAD_ENTRIES, + handler=tools.get_unread_entries, + ) diff --git a/infra/hermes/plugins/miniflux/plugin.yaml b/infra/hermes/plugins/miniflux/plugin.yaml new file mode 100644 index 0000000..8707585 --- /dev/null +++ b/infra/hermes/plugins/miniflux/plugin.yaml @@ -0,0 +1,10 @@ +name: miniflux +version: 1.0.0 +description: Read feeds and entries from the local Miniflux RSS reader +requires_env: + MINIFLUX_API_KEY: + description: Miniflux API key (generate in Miniflux → Settings → API Keys) + secret: true +provides_tools: + - list_feeds + - get_unread_entries diff --git a/infra/hermes/plugins/miniflux/schemas.py b/infra/hermes/plugins/miniflux/schemas.py new file mode 100644 index 0000000..b82ce3d --- /dev/null +++ b/infra/hermes/plugins/miniflux/schemas.py @@ -0,0 +1,34 @@ +LIST_FEEDS = { + "name": "list_feeds", + "description": ( + "List all subscribed RSS feeds from Miniflux. " + "Returns feed titles, URLs, and unread counts." + ), + "parameters": { + "type": "object", + "properties": {}, + "required": [], + }, +} + +GET_UNREAD_ENTRIES = { + "name": "get_unread_entries", + "description": ( + "Get unread entries from Miniflux. " + "Optionally filter by feed ID and limit the number of results." + ), + "parameters": { + "type": "object", + "properties": { + "feed_id": { + "type": "integer", + "description": "Filter to a specific feed. Omit for all feeds.", + }, + "limit": { + "type": "integer", + "description": "Maximum number of entries to return. Defaults to 20.", + }, + }, + "required": [], + }, +} diff --git a/infra/hermes/plugins/miniflux/tools.py b/infra/hermes/plugins/miniflux/tools.py new file mode 100644 index 0000000..ebe19cb --- /dev/null +++ b/infra/hermes/plugins/miniflux/tools.py @@ -0,0 +1,67 @@ +import json +import os +import urllib.request +import urllib.error + +MINIFLUX_BASE = os.environ.get("MINIFLUX_BASE_URL", "http://miniflux:8080") +MINIFLUX_KEY = os.environ.get("MINIFLUX_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 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", {}) + + 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), + }) + result.sort(key=lambda x: x["unread"], reverse=True) + return json.dumps({"feeds": result, "total": len(result)}) + except Exception as e: + return json.dumps({"error": str(e)}) + + +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" + else: + path = f"/entries?status=unread&limit={limit}&direction=desc&order=published_at" + + data = _request(path) + entries = [] + for e in data.get("entries", []): + entries.append({ + "id": e["id"], + "title": e["title"], + "url": e.get("url", ""), + "feed": e.get("feed", {}).get("title", ""), + "author": e.get("author", ""), + "published_at": e.get("published_at", ""), + "reading_time": e.get("reading_time", 0), + }) + return json.dumps({ + "entries": entries, + "total": data.get("total", len(entries)), + }) + except Exception as e: + return json.dumps({"error": str(e)}) diff --git a/infra/miniflux/docker-compose.yml b/infra/miniflux/docker-compose.yml index 10d6400..b6b9185 100644 --- a/infra/miniflux/docker-compose.yml +++ b/infra/miniflux/docker-compose.yml @@ -12,6 +12,9 @@ services: condition: service_healthy ports: - "127.0.0.1:8080:8080" + networks: + - default + - monotrope environment: DATABASE_URL: "postgres://miniflux:${MINIFLUX_DB_PASSWORD}@db/miniflux?sslmode=disable" RUN_MIGRATIONS: "1" @@ -44,5 +47,10 @@ services: timeout: 5s retries: 5 +networks: + default: + monotrope: + external: true + volumes: miniflux_db: