Add shared Docker network and Miniflux plugin for Hermes

- Create external 'monotrope' Docker network so services can
  communicate by container name
- Add Miniflux to the shared network (db stays on internal network)
- Add Hermes Miniflux plugin with list_feeds and get_unread_entries tools
- Mount plugin directory and pass Miniflux API key to Hermes container

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Louis Simoneau
2026-04-10 16:16:34 +10:00
parent 3a9e3a7916
commit bbeecde448
7 changed files with 166 additions and 0 deletions

View File

@@ -17,6 +17,7 @@
hermes_openrouter_api_key: "{{ lookup('env', 'HERMES_OPENROUTER_API_KEY') }}" hermes_openrouter_api_key: "{{ lookup('env', 'HERMES_OPENROUTER_API_KEY') }}"
hermes_telegram_bot_token: "{{ lookup('env', 'HERMES_TELEGRAM_BOT_TOKEN') }}" hermes_telegram_bot_token: "{{ lookup('env', 'HERMES_TELEGRAM_BOT_TOKEN') }}"
hermes_telegram_allowed_users: "{{ lookup('env', 'HERMES_TELEGRAM_ALLOWED_USERS') }}" hermes_telegram_allowed_users: "{{ lookup('env', 'HERMES_TELEGRAM_ALLOWED_USERS') }}"
hermes_miniflux_api_key: "{{ lookup('env', 'HERMES_MINIFLUX_API_KEY') }}"
tasks: tasks:
@@ -238,6 +239,15 @@
enabled: true enabled: true
state: started 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 ──────────────────────────────────────────────────────────── # ── Miniflux ────────────────────────────────────────────────────────────
- name: Create Miniflux directory - name: Create Miniflux directory
@@ -344,6 +354,17 @@
notify: Restart Hermes notify: Restart Hermes
tags: 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 - name: Write Hermes .env
copy: copy:
dest: /opt/hermes/.env dest: /opt/hermes/.env
@@ -354,6 +375,7 @@
OPENROUTER_API_KEY={{ hermes_openrouter_api_key }} OPENROUTER_API_KEY={{ hermes_openrouter_api_key }}
TELEGRAM_BOT_TOKEN={{ hermes_telegram_bot_token }} TELEGRAM_BOT_TOKEN={{ hermes_telegram_bot_token }}
TELEGRAM_ALLOWED_USERS={{ hermes_telegram_allowed_users }} TELEGRAM_ALLOWED_USERS={{ hermes_telegram_allowed_users }}
MINIFLUX_API_KEY={{ hermes_miniflux_api_key }}
no_log: true no_log: true
tags: hermes tags: hermes

View File

@@ -9,15 +9,24 @@ services:
options: options:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
networks:
- monotrope
volumes: volumes:
- hermes_data:/opt/data - hermes_data:/opt/data
- ./config.yaml:/opt/data/config.yaml:ro - ./config.yaml:/opt/data/config.yaml:ro
- ./plugins:/opt/data/plugins:ro
environment: environment:
OPENROUTER_API_KEY: "${OPENROUTER_API_KEY}" OPENROUTER_API_KEY: "${OPENROUTER_API_KEY}"
TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN}" TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN}"
TELEGRAM_ALLOWED_USERS: "${TELEGRAM_ALLOWED_USERS}" TELEGRAM_ALLOWED_USERS: "${TELEGRAM_ALLOWED_USERS}"
MINIFLUX_API_KEY: "${MINIFLUX_API_KEY}"
MINIFLUX_BASE_URL: "http://miniflux:8080"
env_file: env_file:
- .env - .env
networks:
monotrope:
external: true
volumes: volumes:
hermes_data: hermes_data:

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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": [],
},
}

View File

@@ -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)})

View File

@@ -12,6 +12,9 @@ services:
condition: service_healthy condition: service_healthy
ports: ports:
- "127.0.0.1:8080:8080" - "127.0.0.1:8080:8080"
networks:
- default
- monotrope
environment: environment:
DATABASE_URL: "postgres://miniflux:${MINIFLUX_DB_PASSWORD}@db/miniflux?sslmode=disable" DATABASE_URL: "postgres://miniflux:${MINIFLUX_DB_PASSWORD}@db/miniflux?sslmode=disable"
RUN_MIGRATIONS: "1" RUN_MIGRATIONS: "1"
@@ -44,5 +47,10 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
networks:
default:
monotrope:
external: true
volumes: volumes:
miniflux_db: miniflux_db: