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:
16
infra/hermes/plugins/miniflux/__init__.py
Normal file
16
infra/hermes/plugins/miniflux/__init__.py
Normal 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,
|
||||
)
|
||||
10
infra/hermes/plugins/miniflux/plugin.yaml
Normal file
10
infra/hermes/plugins/miniflux/plugin.yaml
Normal 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
|
||||
34
infra/hermes/plugins/miniflux/schemas.py
Normal file
34
infra/hermes/plugins/miniflux/schemas.py
Normal 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": [],
|
||||
},
|
||||
}
|
||||
67
infra/hermes/plugins/miniflux/tools.py
Normal file
67
infra/hermes/plugins/miniflux/tools.py
Normal 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)})
|
||||
Reference in New Issue
Block a user