freshrss-agent 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- freshrss_agent/__init__.py +54 -0
- freshrss_agent/__main__.py +5 -0
- freshrss_agent/agent_data/IDENTITY.md +15 -0
- freshrss_agent/agent_server.py +73 -0
- freshrss_agent/api/__init__.py +19 -0
- freshrss_agent/api/api_client_base.py +137 -0
- freshrss_agent/api/api_client_reader.py +70 -0
- freshrss_agent/api/api_client_subscriptions.py +78 -0
- freshrss_agent/api_client.py +7 -0
- freshrss_agent/auth.py +100 -0
- freshrss_agent/freshrss_input_models.py +70 -0
- freshrss_agent/freshrss_response_models.py +70 -0
- freshrss_agent/main_agent.json +14 -0
- freshrss_agent/mcp/__init__.py +4 -0
- freshrss_agent/mcp/mcp_reader.py +65 -0
- freshrss_agent/mcp/mcp_subscriptions.py +81 -0
- freshrss_agent/mcp_config.json +3 -0
- freshrss_agent/mcp_server.py +63 -0
- freshrss_agent-0.1.0.dist-info/METADATA +227 -0
- freshrss_agent-0.1.0.dist-info/RECORD +36 -0
- freshrss_agent-0.1.0.dist-info/WHEEL +5 -0
- freshrss_agent-0.1.0.dist-info/entry_points.txt +3 -0
- freshrss_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- freshrss_agent-0.1.0.dist-info/top_level.txt +3 -0
- scripts/security_sanitizer.py +160 -0
- scripts/validate_a2a_agent.py +139 -0
- scripts/validate_agent.py +15 -0
- scripts/verify_api_integration.py +280 -0
- tests/__init__.py +0 -0
- tests/conftest.py +10 -0
- tests/test_api_wrapper.py +82 -0
- tests/test_auth.py +17 -0
- tests/test_concept_parity.py +23 -0
- tests/test_freshrss_mcp_validation.py +19 -0
- tests/test_init_dynamics.py +10 -0
- tests/test_startup.py +9 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import inspect
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
warnings.filterwarnings("ignore", message=".*urllib3.*or chardet.*")
|
|
8
|
+
|
|
9
|
+
__all__: list[str] = []
|
|
10
|
+
|
|
11
|
+
CORE_MODULES = [
|
|
12
|
+
"freshrss_agent.api",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
OPTIONAL_MODULES = {
|
|
16
|
+
"freshrss_agent.agent_server": "agent",
|
|
17
|
+
"freshrss_agent.mcp_server": "mcp",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _import_module_safely(module_name: str):
|
|
22
|
+
"""Try to import a module and return it, or None if not available."""
|
|
23
|
+
try:
|
|
24
|
+
return importlib.import_module(module_name)
|
|
25
|
+
except ImportError:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _expose_members(module):
|
|
30
|
+
"""Expose public classes and functions from a module into globals and __all__."""
|
|
31
|
+
for name, obj in inspect.getmembers(module):
|
|
32
|
+
if (inspect.isclass(obj) or inspect.isfunction(obj)) and not name.startswith(
|
|
33
|
+
"_"
|
|
34
|
+
):
|
|
35
|
+
globals()[name] = obj
|
|
36
|
+
__all__.append(name)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
for module_name in CORE_MODULES:
|
|
40
|
+
try:
|
|
41
|
+
module = importlib.import_module(module_name)
|
|
42
|
+
_expose_members(module)
|
|
43
|
+
except ImportError:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
for module_name, extra_name in OPTIONAL_MODULES.items():
|
|
47
|
+
module = _import_module_safely(module_name)
|
|
48
|
+
if module is not None:
|
|
49
|
+
_expose_members(module)
|
|
50
|
+
globals()[f"_{extra_name.upper()}_AVAILABLE"] = True
|
|
51
|
+
else:
|
|
52
|
+
globals()[f"_{extra_name.upper()}_AVAILABLE"] = False
|
|
53
|
+
|
|
54
|
+
__all__.extend(["_MCP_AVAILABLE", "_AGENT_AVAILABLE"])
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# IDENTITY.md - FreshRSS Agent Identity
|
|
2
|
+
|
|
3
|
+
## [default]
|
|
4
|
+
* **Name:** FreshRSS Agent
|
|
5
|
+
* **Role:** FreshRSS API + MCP Server + A2A Server
|
|
6
|
+
* **Emoji:** 🤖
|
|
7
|
+
|
|
8
|
+
### System Prompt
|
|
9
|
+
You are the FreshRSS Agent.
|
|
10
|
+
Use the `mcp-client` universal skill and check the reference documentation for
|
|
11
|
+
`freshrss-agent.md` to discover the exact tags and tools available for your capabilities.
|
|
12
|
+
|
|
13
|
+
### Capabilities
|
|
14
|
+
- **MCP Operations**: Leverage the `mcp-client` skill to interact with the target MCP server.
|
|
15
|
+
- **Custom Agent**: Handle custom tasks or general tasks.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
logging.basicConfig(
|
|
10
|
+
level=logging.INFO,
|
|
11
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
12
|
+
handlers=[logging.StreamHandler()],
|
|
13
|
+
)
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def agent_server():
|
|
18
|
+
from agent_utilities import (
|
|
19
|
+
build_system_prompt_from_workspace,
|
|
20
|
+
create_agent_parser,
|
|
21
|
+
create_agent_server,
|
|
22
|
+
initialize_workspace,
|
|
23
|
+
load_identity,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
warnings.filterwarnings("ignore", message=".*urllib3.*or chardet.*")
|
|
27
|
+
warnings.filterwarnings("ignore", category=DeprecationWarning, module="fastmcp")
|
|
28
|
+
|
|
29
|
+
initialize_workspace()
|
|
30
|
+
meta = load_identity()
|
|
31
|
+
agent_name = os.getenv("DEFAULT_AGENT_NAME", meta.get("name", "FreshRSS"))
|
|
32
|
+
|
|
33
|
+
print(f"{agent_name} v{__version__}", file=sys.stderr)
|
|
34
|
+
parser = create_agent_parser()
|
|
35
|
+
args = parser.parse_args()
|
|
36
|
+
|
|
37
|
+
if args.debug:
|
|
38
|
+
logging.getLogger().setLevel(logging.DEBUG)
|
|
39
|
+
logger.debug("Debug mode enabled")
|
|
40
|
+
|
|
41
|
+
create_agent_server(
|
|
42
|
+
mcp_url=args.mcp_url,
|
|
43
|
+
mcp_config=args.mcp_config or "mcp_config.json",
|
|
44
|
+
host=args.host,
|
|
45
|
+
port=args.port,
|
|
46
|
+
provider=args.provider,
|
|
47
|
+
model_id=args.model_id,
|
|
48
|
+
router_model=args.model_id,
|
|
49
|
+
agent_model=args.model_id,
|
|
50
|
+
base_url=args.base_url,
|
|
51
|
+
api_key=args.api_key,
|
|
52
|
+
agent_description=os.getenv(
|
|
53
|
+
"AGENT_DESCRIPTION",
|
|
54
|
+
meta.get("description", "FreshRSS API + MCP Server + A2A Server"),
|
|
55
|
+
),
|
|
56
|
+
system_prompt=os.getenv(
|
|
57
|
+
"AGENT_SYSTEM_PROMPT",
|
|
58
|
+
meta.get("content") or build_system_prompt_from_workspace(),
|
|
59
|
+
),
|
|
60
|
+
custom_skills_directory=args.custom_skills_directory,
|
|
61
|
+
enable_web_ui=args.web,
|
|
62
|
+
enable_otel=args.otel,
|
|
63
|
+
otel_endpoint=args.otel_endpoint,
|
|
64
|
+
otel_headers=args.otel_headers,
|
|
65
|
+
otel_public_key=args.otel_public_key,
|
|
66
|
+
otel_secret_key=args.otel_secret_key,
|
|
67
|
+
otel_protocol=args.otel_protocol,
|
|
68
|
+
debug=args.debug,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
agent_server()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .api_client_base import FreshRSSClientBase
|
|
2
|
+
from .api_client_reader import ReaderMixin
|
|
3
|
+
from .api_client_subscriptions import SubscriptionsMixin
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FreshRSSApi(ReaderMixin, SubscriptionsMixin, FreshRSSClientBase):
|
|
7
|
+
"""FreshRSS GReader API client (reader + subscriptions)."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Backward-compat alias for the scaffold (auth.py imports ApiClientSystem).
|
|
11
|
+
ApiClientSystem = FreshRSSApi
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"FreshRSSApi",
|
|
15
|
+
"FreshRSSClientBase",
|
|
16
|
+
"ReaderMixin",
|
|
17
|
+
"SubscriptionsMixin",
|
|
18
|
+
"ApiClientSystem",
|
|
19
|
+
]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
"""GReader (Google Reader compatible) HTTP base client for FreshRSS."""
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
import urllib3
|
|
9
|
+
from agent_utilities.base_utilities import get_logger
|
|
10
|
+
from agent_utilities.core.exceptions import AuthError, UnauthorizedError
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FreshRSSClientBase:
|
|
16
|
+
"""Base client for the FreshRSS Google Reader compatible API (GReader).
|
|
17
|
+
|
|
18
|
+
Implements the GReader auth flow:
|
|
19
|
+
1. ``ClientLogin`` exchanges ``Email``/``Passwd`` for an ``Auth`` token.
|
|
20
|
+
2. Authenticated requests send ``Authorization: GoogleLogin auth=<token>``.
|
|
21
|
+
3. Write actions require a short-lived POST token (``T`` form field) fetched
|
|
22
|
+
from the ``token`` endpoint.
|
|
23
|
+
4. On HTTP 401 the client re-runs ClientLogin once and retries.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
base_url: str | None,
|
|
29
|
+
username: str | None,
|
|
30
|
+
api_password: str | None,
|
|
31
|
+
verify: bool = True,
|
|
32
|
+
):
|
|
33
|
+
self.base_url = (base_url or "").rstrip("/")
|
|
34
|
+
self.username = username or ""
|
|
35
|
+
self.api_password = api_password or ""
|
|
36
|
+
self.verify = verify
|
|
37
|
+
self.session = requests.Session()
|
|
38
|
+
self._auth_token: str | None = None
|
|
39
|
+
|
|
40
|
+
if self.verify is False:
|
|
41
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
42
|
+
|
|
43
|
+
# -- internals -----------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def _greader_url(self, path: str) -> str:
|
|
46
|
+
return f"{self.base_url}/api/greader.php/{path.lstrip('/')}"
|
|
47
|
+
|
|
48
|
+
def _login(self) -> str:
|
|
49
|
+
"""Run ClientLogin and cache the ``Auth`` token."""
|
|
50
|
+
url = self._greader_url("accounts/ClientLogin")
|
|
51
|
+
response = self.session.post(
|
|
52
|
+
url,
|
|
53
|
+
data={"Email": self.username, "Passwd": self.api_password},
|
|
54
|
+
verify=self.verify,
|
|
55
|
+
timeout=30,
|
|
56
|
+
)
|
|
57
|
+
if response.status_code in (401, 403):
|
|
58
|
+
logger.error("FreshRSS ClientLogin failed: %s", response.status_code)
|
|
59
|
+
raise AuthError if response.status_code == 401 else UnauthorizedError
|
|
60
|
+
response.raise_for_status()
|
|
61
|
+
token: str | None = None
|
|
62
|
+
for line in response.text.splitlines():
|
|
63
|
+
if line.startswith("Auth="):
|
|
64
|
+
token = line[len("Auth=") :].strip()
|
|
65
|
+
break
|
|
66
|
+
if not token:
|
|
67
|
+
raise AuthError
|
|
68
|
+
self._auth_token = token
|
|
69
|
+
return token
|
|
70
|
+
|
|
71
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
72
|
+
if not self._auth_token:
|
|
73
|
+
self._login()
|
|
74
|
+
return {"Authorization": f"GoogleLogin auth={self._auth_token}"}
|
|
75
|
+
|
|
76
|
+
def _get_write_token(self) -> str:
|
|
77
|
+
"""Fetch a short-lived POST token required by write actions."""
|
|
78
|
+
response = self.session.get(
|
|
79
|
+
self._greader_url("reader/api/0/token"),
|
|
80
|
+
headers=self._auth_headers(),
|
|
81
|
+
verify=self.verify,
|
|
82
|
+
timeout=30,
|
|
83
|
+
)
|
|
84
|
+
if response.status_code == 401:
|
|
85
|
+
self._login()
|
|
86
|
+
response = self.session.get(
|
|
87
|
+
self._greader_url("reader/api/0/token"),
|
|
88
|
+
headers=self._auth_headers(),
|
|
89
|
+
verify=self.verify,
|
|
90
|
+
timeout=30,
|
|
91
|
+
)
|
|
92
|
+
response.raise_for_status()
|
|
93
|
+
return response.text.strip()
|
|
94
|
+
|
|
95
|
+
def request(
|
|
96
|
+
self,
|
|
97
|
+
method: str,
|
|
98
|
+
path: str,
|
|
99
|
+
params: dict[str, Any] | None = None,
|
|
100
|
+
data: Any | None = None,
|
|
101
|
+
) -> Any:
|
|
102
|
+
"""Issue an authenticated GReader request, retrying once on 401.
|
|
103
|
+
|
|
104
|
+
``output=json`` is always injected so JSON responses are returned. The
|
|
105
|
+
JSON body is decoded when possible; otherwise the raw text is returned.
|
|
106
|
+
"""
|
|
107
|
+
params = dict(params or {})
|
|
108
|
+
params.setdefault("output", "json")
|
|
109
|
+
url = self._greader_url(path)
|
|
110
|
+
|
|
111
|
+
def _send() -> requests.Response:
|
|
112
|
+
return self.session.request(
|
|
113
|
+
method,
|
|
114
|
+
url,
|
|
115
|
+
params=params,
|
|
116
|
+
data=data,
|
|
117
|
+
headers=self._auth_headers(),
|
|
118
|
+
verify=self.verify,
|
|
119
|
+
timeout=60,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
response = _send()
|
|
123
|
+
if response.status_code == 401:
|
|
124
|
+
# Token expired — re-login once and retry.
|
|
125
|
+
self._auth_token = None
|
|
126
|
+
response = _send()
|
|
127
|
+
if response.status_code == 401:
|
|
128
|
+
logger.error("FreshRSS request unauthorized after re-login: %s", url)
|
|
129
|
+
raise AuthError
|
|
130
|
+
response.raise_for_status()
|
|
131
|
+
try:
|
|
132
|
+
return response.json()
|
|
133
|
+
except ValueError:
|
|
134
|
+
return {"status": response.status_code, "text": response.text}
|
|
135
|
+
|
|
136
|
+
def set_debug(self, debug: bool = False) -> None:
|
|
137
|
+
logger.setLevel(logging.DEBUG if debug else logging.ERROR)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
"""Reader operations for the FreshRSS GReader API (stream contents, item bodies)."""
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ReaderMixin:
|
|
8
|
+
"""Read-side GReader operations: stream contents, item bodies, unread counts."""
|
|
9
|
+
|
|
10
|
+
request: Any
|
|
11
|
+
|
|
12
|
+
def stream_contents(
|
|
13
|
+
self,
|
|
14
|
+
stream_id: str = "user/-/state/com.google/reading-list",
|
|
15
|
+
count: int = 100,
|
|
16
|
+
order: str = "o",
|
|
17
|
+
newer_than: int | None = None,
|
|
18
|
+
continuation: str | None = None,
|
|
19
|
+
) -> dict[str, Any]:
|
|
20
|
+
"""Fetch items for a stream.
|
|
21
|
+
|
|
22
|
+
``stream_id`` is a GReader stream id (e.g.
|
|
23
|
+
``user/-/state/com.google/reading-list`` for all items, ``feed/<feedUrl>``
|
|
24
|
+
for one feed, or ``user/-/label/<category>``). ``order`` is ``o`` (oldest
|
|
25
|
+
first) or ``n`` (newest first). ``newer_than`` is a unix-seconds watermark
|
|
26
|
+
mapped to the GReader ``ot`` parameter (exclude items older than this).
|
|
27
|
+
``continuation`` resumes a previous page. Returns the raw
|
|
28
|
+
``{"items": [...], "continuation": "..."}`` payload.
|
|
29
|
+
"""
|
|
30
|
+
params: dict[str, Any] = {"n": count, "r": order}
|
|
31
|
+
if newer_than is not None:
|
|
32
|
+
params["ot"] = int(newer_than)
|
|
33
|
+
if continuation:
|
|
34
|
+
params["c"] = continuation
|
|
35
|
+
result = self.request(
|
|
36
|
+
"GET",
|
|
37
|
+
f"reader/api/0/stream/contents/{stream_id}",
|
|
38
|
+
params=params,
|
|
39
|
+
)
|
|
40
|
+
# Normalize each item to flat, transport-safe top-level fields so a KG
|
|
41
|
+
# connector reads ``text`` directly (a nested ``summary.content`` field-map
|
|
42
|
+
# does not survive the MCP structured-output round-trip reliably). The raw
|
|
43
|
+
# GReader fields (origin/categories/canonical) are preserved alongside.
|
|
44
|
+
if isinstance(result, dict) and isinstance(result.get("items"), list):
|
|
45
|
+
for item in result["items"]:
|
|
46
|
+
if not isinstance(item, dict):
|
|
47
|
+
continue
|
|
48
|
+
body = (item.get("summary") or {}).get("content") or (
|
|
49
|
+
item.get("content") or {}
|
|
50
|
+
).get("content") or ""
|
|
51
|
+
item["text"] = body
|
|
52
|
+
canonical = item.get("canonical") or []
|
|
53
|
+
if isinstance(canonical, list) and canonical:
|
|
54
|
+
item["url"] = canonical[0].get("href", "")
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
def item_contents(self, item_ids: list[str] | str) -> dict[str, Any]:
|
|
58
|
+
"""Fetch full contents for specific item ids (GReader ``i`` parameters)."""
|
|
59
|
+
if isinstance(item_ids, str):
|
|
60
|
+
item_ids = [item_ids]
|
|
61
|
+
data = [("i", item_id) for item_id in item_ids]
|
|
62
|
+
return self.request(
|
|
63
|
+
"POST",
|
|
64
|
+
"reader/api/0/stream/items/contents",
|
|
65
|
+
data=data,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def unread_count(self) -> dict[str, Any]:
|
|
69
|
+
"""Return unread counts per stream."""
|
|
70
|
+
return self.request("GET", "reader/api/0/unread-count")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
"""Subscription, category and tagging operations for the FreshRSS GReader API."""
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SubscriptionsMixin:
|
|
8
|
+
"""Manage feeds, categories and item tags (read/star) via the GReader API."""
|
|
9
|
+
|
|
10
|
+
request: Any
|
|
11
|
+
_get_write_token: Any
|
|
12
|
+
|
|
13
|
+
def subscription_list(self) -> dict[str, Any]:
|
|
14
|
+
"""List all feed subscriptions."""
|
|
15
|
+
return self.request("GET", "reader/api/0/subscription/list")
|
|
16
|
+
|
|
17
|
+
def categories(self) -> dict[str, Any]:
|
|
18
|
+
"""List categories / tags (``tag/list``)."""
|
|
19
|
+
return self.request("GET", "reader/api/0/tag/list")
|
|
20
|
+
|
|
21
|
+
def _stream_for_feed(self, feed_url: str) -> str:
|
|
22
|
+
return feed_url if feed_url.startswith("feed/") else f"feed/{feed_url}"
|
|
23
|
+
|
|
24
|
+
def subscribe(
|
|
25
|
+
self,
|
|
26
|
+
feed_url: str,
|
|
27
|
+
title: str | None = None,
|
|
28
|
+
category: str | None = None,
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
"""Subscribe to a feed, optionally setting its title and category."""
|
|
31
|
+
data: list[tuple[str, str]] = [
|
|
32
|
+
("ac", "subscribe"),
|
|
33
|
+
("s", self._stream_for_feed(feed_url)),
|
|
34
|
+
("T", self._get_write_token()),
|
|
35
|
+
]
|
|
36
|
+
if title:
|
|
37
|
+
data.append(("t", title))
|
|
38
|
+
if category:
|
|
39
|
+
data.append(("a", f"user/-/label/{category}"))
|
|
40
|
+
return self.request("POST", "reader/api/0/subscription/edit", data=data)
|
|
41
|
+
|
|
42
|
+
def unsubscribe(self, feed_url: str) -> dict[str, Any]:
|
|
43
|
+
"""Unsubscribe from a feed."""
|
|
44
|
+
data = [
|
|
45
|
+
("ac", "unsubscribe"),
|
|
46
|
+
("s", self._stream_for_feed(feed_url)),
|
|
47
|
+
("T", self._get_write_token()),
|
|
48
|
+
]
|
|
49
|
+
return self.request("POST", "reader/api/0/subscription/edit", data=data)
|
|
50
|
+
|
|
51
|
+
def label(self, feed_url: str, category: str) -> dict[str, Any]:
|
|
52
|
+
"""Add a category label to an existing feed subscription."""
|
|
53
|
+
data = [
|
|
54
|
+
("ac", "edit"),
|
|
55
|
+
("s", self._stream_for_feed(feed_url)),
|
|
56
|
+
("a", f"user/-/label/{category}"),
|
|
57
|
+
("T", self._get_write_token()),
|
|
58
|
+
]
|
|
59
|
+
return self.request("POST", "reader/api/0/subscription/edit", data=data)
|
|
60
|
+
|
|
61
|
+
def mark_read(self, item_ids: list[str] | str) -> dict[str, Any]:
|
|
62
|
+
"""Mark one or more items as read."""
|
|
63
|
+
if isinstance(item_ids, str):
|
|
64
|
+
item_ids = [item_ids]
|
|
65
|
+
data: list[tuple[str, str]] = [("T", self._get_write_token())]
|
|
66
|
+
data.append(("a", "user/-/state/com.google/read"))
|
|
67
|
+
data.extend(("i", item_id) for item_id in item_ids)
|
|
68
|
+
return self.request("POST", "reader/api/0/edit-tag", data=data)
|
|
69
|
+
|
|
70
|
+
def star(self, item_id: str, starred: bool = True) -> dict[str, Any]:
|
|
71
|
+
"""Star or unstar an item."""
|
|
72
|
+
key = "a" if starred else "r"
|
|
73
|
+
data = [
|
|
74
|
+
("T", self._get_write_token()),
|
|
75
|
+
(key, "user/-/state/com.google/starred"),
|
|
76
|
+
("i", item_id),
|
|
77
|
+
]
|
|
78
|
+
return self.request("POST", "reader/api/0/edit-tag", data=data)
|
freshrss_agent/auth.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
|
|
3
|
+
"""Authentication.
|
|
4
|
+
|
|
5
|
+
Priority:
|
|
6
|
+
1. **OIDC Delegation** (RFC 8693 Token Exchange) — when ``ENABLE_DELEGATION`` is
|
|
7
|
+
active, exchanges the IdP-issued user token for a downstream access token via the
|
|
8
|
+
shared ``agent_utilities.mcp.delegated_auth`` helper.
|
|
9
|
+
2. **Fixed credentials** — falls back to the ``FRESHRSS_API_PASSWORD`` env var.
|
|
10
|
+
|
|
11
|
+
For a multi-tenant service, add an ``instances.py`` that resolves a configured
|
|
12
|
+
instance NAME (from ``<service>_instances`` in ``~/.config/agent-utilities/config.json``)
|
|
13
|
+
to ``(url, token, verify)`` and call it here before the delegation/fixed paths — see
|
|
14
|
+
``gitlab_api.instances`` (CONCEPT:KG-2.9g) for the golden pattern.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
from agent_utilities.base_utilities import get_logger, to_boolean
|
|
20
|
+
from agent_utilities.core.exceptions import AuthError, UnauthorizedError
|
|
21
|
+
|
|
22
|
+
from .api import ApiClientSystem
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
_client = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_client(
|
|
29
|
+
url: str | None = None,
|
|
30
|
+
token: str | None = None,
|
|
31
|
+
verify: bool | None = None,
|
|
32
|
+
config: dict | None = None,
|
|
33
|
+
) -> ApiClientSystem:
|
|
34
|
+
"""Get or create a singleton API client (OIDC delegation or fixed credentials)."""
|
|
35
|
+
global _client
|
|
36
|
+
if _client is not None:
|
|
37
|
+
return _client
|
|
38
|
+
|
|
39
|
+
base_url = url or os.getenv("FRESHRSS_URL", "http://localhost:8080")
|
|
40
|
+
api_password = token or os.getenv("FRESHRSS_API_PASSWORD", "")
|
|
41
|
+
username = os.getenv("FRESHRSS_USER", "")
|
|
42
|
+
if verify is None:
|
|
43
|
+
verify = to_boolean(string=os.getenv("FRESHRSS_SSL_VERIFY", "True"))
|
|
44
|
+
|
|
45
|
+
from agent_utilities.mcp.delegated_auth import (
|
|
46
|
+
get_delegated_token,
|
|
47
|
+
get_user_identity,
|
|
48
|
+
is_delegation_enabled,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# --- Path 1: OIDC Delegation (RFC 8693 Token Exchange) ---
|
|
52
|
+
if is_delegation_enabled(config):
|
|
53
|
+
try:
|
|
54
|
+
delegated_token = get_delegated_token(
|
|
55
|
+
config=config,
|
|
56
|
+
audience=(config or {}).get("audience", base_url),
|
|
57
|
+
scopes=(config or {}).get("delegated_scopes", "api"),
|
|
58
|
+
verify=verify,
|
|
59
|
+
)
|
|
60
|
+
identity = get_user_identity()
|
|
61
|
+
logger.info(
|
|
62
|
+
"Using OIDC delegated token",
|
|
63
|
+
extra={"user_email": identity.get("email"), "url": base_url},
|
|
64
|
+
)
|
|
65
|
+
_client = ApiClientSystem(
|
|
66
|
+
base_url=base_url,
|
|
67
|
+
username=username,
|
|
68
|
+
api_password=delegated_token,
|
|
69
|
+
verify=verify,
|
|
70
|
+
)
|
|
71
|
+
return _client
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.error(
|
|
74
|
+
"OIDC delegation failed",
|
|
75
|
+
extra={"error_type": type(e).__name__, "error_message": str(e)},
|
|
76
|
+
)
|
|
77
|
+
raise RuntimeError(f"Token exchange failed: {str(e)}") from e
|
|
78
|
+
|
|
79
|
+
# --- Path 2: Fixed Credentials (FRESHRSS_API_PASSWORD) ---
|
|
80
|
+
logger.info("Using fixed credentials")
|
|
81
|
+
try:
|
|
82
|
+
_client = ApiClientSystem(
|
|
83
|
+
base_url=base_url,
|
|
84
|
+
username=username,
|
|
85
|
+
api_password=api_password,
|
|
86
|
+
verify=verify,
|
|
87
|
+
)
|
|
88
|
+
except (AuthError, UnauthorizedError) as e:
|
|
89
|
+
raise RuntimeError(
|
|
90
|
+
f"AUTHENTICATION ERROR: The credentials provided are not valid for '{base_url}'. "
|
|
91
|
+
f"Please check your FRESHRSS_API_PASSWORD and FRESHRSS_URL environment variables. "
|
|
92
|
+
f"Error details: {str(e)}"
|
|
93
|
+
) from e
|
|
94
|
+
except Exception as e:
|
|
95
|
+
raise RuntimeError(
|
|
96
|
+
f"AUTHENTICATION ERROR: Failed to instantiate client. "
|
|
97
|
+
f"Error details: {str(e)}"
|
|
98
|
+
) from e
|
|
99
|
+
|
|
100
|
+
return _client
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
"""Pydantic input models for FreshRSS GReader API request parameters."""
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class StreamContentsParams(BaseModel):
|
|
8
|
+
"""Parameters for fetching stream contents."""
|
|
9
|
+
|
|
10
|
+
stream_id: str = Field(
|
|
11
|
+
default="user/-/state/com.google/reading-list",
|
|
12
|
+
description="GReader stream id (reading-list, feed/<url>, user/-/label/<cat>).",
|
|
13
|
+
)
|
|
14
|
+
count: int = Field(
|
|
15
|
+
default=100, description="Maximum number of items (GReader 'n')."
|
|
16
|
+
)
|
|
17
|
+
order: str = Field(
|
|
18
|
+
default="o", description="'o' oldest-first or 'n' newest-first (GReader 'r')."
|
|
19
|
+
)
|
|
20
|
+
newer_than: int | None = Field(
|
|
21
|
+
default=None,
|
|
22
|
+
description="Unix-seconds watermark; exclude items older than this (GReader 'ot').",
|
|
23
|
+
)
|
|
24
|
+
continuation: str | None = Field(
|
|
25
|
+
default=None, description="Continuation token for pagination (GReader 'c')."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ItemContentsParams(BaseModel):
|
|
30
|
+
"""Parameters for fetching specific item bodies."""
|
|
31
|
+
|
|
32
|
+
item_ids: list[str] | str = Field(
|
|
33
|
+
description="One or more GReader long-form item ids."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SubscribeParams(BaseModel):
|
|
38
|
+
"""Parameters for subscribing to a feed."""
|
|
39
|
+
|
|
40
|
+
feed_url: str = Field(description="Feed URL (or 'feed/<url>' stream id).")
|
|
41
|
+
title: str | None = Field(default=None, description="Optional feed title.")
|
|
42
|
+
category: str | None = Field(
|
|
43
|
+
default=None, description="Optional category label to assign."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class UnsubscribeParams(BaseModel):
|
|
48
|
+
"""Parameters for unsubscribing from a feed."""
|
|
49
|
+
|
|
50
|
+
feed_url: str = Field(description="Feed URL (or 'feed/<url>' stream id).")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class LabelParams(BaseModel):
|
|
54
|
+
"""Parameters for adding a category label to a feed."""
|
|
55
|
+
|
|
56
|
+
feed_url: str = Field(description="Feed URL (or 'feed/<url>' stream id).")
|
|
57
|
+
category: str = Field(description="Category label to add.")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class MarkReadParams(BaseModel):
|
|
61
|
+
"""Parameters for marking items as read."""
|
|
62
|
+
|
|
63
|
+
item_ids: list[str] | str = Field(description="One or more item ids.")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class StarParams(BaseModel):
|
|
67
|
+
"""Parameters for starring/unstarring an item."""
|
|
68
|
+
|
|
69
|
+
item_id: str = Field(description="The item id to star or unstar.")
|
|
70
|
+
starred: bool = Field(default=True, description="True to star, False to unstar.")
|