agentr 0.1.7__py3-none-any.whl → 0.1.9__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.
- agentr/application.py +40 -17
- agentr/applications/github/app.py +212 -264
- agentr/applications/google_calendar/app.py +349 -408
- agentr/applications/reddit/app.py +308 -8
- agentr/cli.py +31 -0
- agentr/integration.py +79 -3
- agentr/integrations/README.md +25 -0
- agentr/integrations/__init__.py +5 -0
- agentr/integrations/agentr.py +87 -0
- agentr/integrations/api_key.py +16 -0
- agentr/integrations/base.py +60 -0
- agentr/server.py +23 -2
- agentr/test.py +4 -29
- agentr/utils/openapi.py +113 -25
- {agentr-0.1.7.dist-info → agentr-0.1.9.dist-info}/METADATA +3 -3
- agentr-0.1.9.dist-info/RECORD +30 -0
- agentr-0.1.7.dist-info/RECORD +0 -25
- {agentr-0.1.7.dist-info → agentr-0.1.9.dist-info}/WHEEL +0 -0
- {agentr-0.1.7.dist-info → agentr-0.1.9.dist-info}/entry_points.txt +0 -0
- {agentr-0.1.7.dist-info → agentr-0.1.9.dist-info}/licenses/LICENSE +0 -0
@@ -1,29 +1,329 @@
|
|
1
|
+
import httpx
|
1
2
|
from agentr.application import APIApplication
|
2
3
|
from agentr.integration import Integration
|
4
|
+
from agentr.exceptions import NotAuthorizedError
|
5
|
+
from loguru import logger
|
3
6
|
|
4
7
|
class RedditApp(APIApplication):
|
5
8
|
def __init__(self, integration: Integration) -> None:
|
6
9
|
super().__init__(name="reddit", integration=integration)
|
10
|
+
self.base_api_url = "https://oauth.reddit.com"
|
11
|
+
|
12
|
+
def _post(self, url, data):
|
13
|
+
try:
|
14
|
+
headers = self._get_headers()
|
15
|
+
response = httpx.post(url, headers=headers, data=data)
|
16
|
+
response.raise_for_status()
|
17
|
+
return response
|
18
|
+
except NotAuthorizedError as e:
|
19
|
+
logger.warning(f"Authorization needed: {e.message}")
|
20
|
+
raise e
|
21
|
+
except httpx.HTTPStatusError as e:
|
22
|
+
if e.response.status_code == 429:
|
23
|
+
return e.response.text or "Rate limit exceeded. Please try again later."
|
24
|
+
else:
|
25
|
+
raise e
|
26
|
+
except Exception as e:
|
27
|
+
logger.error(f"Error posting {url}: {e}")
|
28
|
+
raise e
|
7
29
|
|
8
30
|
def _get_headers(self):
|
31
|
+
if not self.integration:
|
32
|
+
raise ValueError("Integration not configured for RedditApp")
|
9
33
|
credentials = self.integration.get_credentials()
|
10
|
-
if "
|
11
|
-
|
34
|
+
if "access_token" not in credentials:
|
35
|
+
logger.error("Reddit credentials found but missing 'access_token'.")
|
36
|
+
raise ValueError("Invalid Reddit credentials format.")
|
37
|
+
|
12
38
|
return {
|
13
39
|
"Authorization": f"Bearer {credentials['access_token']}",
|
40
|
+
"User-Agent": "agentr-reddit-app/0.1 by AgentR"
|
14
41
|
}
|
42
|
+
|
43
|
+
def get_subreddit_posts(self, subreddit: str, limit: int = 5, timeframe: str = "day") -> str:
|
44
|
+
"""Get the top posts from a specified subreddit over a given timeframe.
|
45
|
+
|
46
|
+
Args:
|
47
|
+
subreddit: The name of the subreddit (e.g., 'python', 'worldnews') without the 'r/'.
|
48
|
+
limit: The maximum number of posts to return (default: 5, max: 100).
|
49
|
+
timeframe: The time period for top posts. Valid options: 'hour', 'day', 'week', 'month', 'year', 'all' (default: 'day').
|
50
|
+
|
51
|
+
Returns:
|
52
|
+
A formatted string listing the top posts or an error message.
|
53
|
+
"""
|
54
|
+
valid_timeframes = ['hour', 'day', 'week', 'month', 'year', 'all']
|
55
|
+
if timeframe not in valid_timeframes:
|
56
|
+
return f"Error: Invalid timeframe '{timeframe}'. Please use one of: {', '.join(valid_timeframes)}"
|
57
|
+
|
58
|
+
if not 1 <= limit <= 100:
|
59
|
+
return f"Error: Invalid limit '{limit}'. Please use a value between 1 and 100."
|
60
|
+
|
61
|
+
|
62
|
+
url = f"{self.base_api_url}/r/{subreddit}/top"
|
63
|
+
params = {
|
64
|
+
"limit": limit,
|
65
|
+
"t": timeframe
|
66
|
+
}
|
67
|
+
|
68
|
+
logger.info(f"Requesting top {limit} posts from r/{subreddit} for timeframe '{timeframe}'")
|
69
|
+
response = self._get(url, params=params)
|
15
70
|
|
16
|
-
|
17
|
-
|
71
|
+
data = response.json()
|
72
|
+
|
73
|
+
if "error" in data:
|
74
|
+
logger.error(f"Reddit API error: {data['error']} - {data.get('message', '')}")
|
75
|
+
return f"Error from Reddit API: {data['error']} - {data.get('message', '')}"
|
76
|
+
|
77
|
+
posts = data.get("data", {}).get("children", [])
|
78
|
+
|
79
|
+
if not posts:
|
80
|
+
return f"No top posts found in r/{subreddit} for the timeframe '{timeframe}'."
|
81
|
+
|
82
|
+
result_lines = [f"Top {len(posts)} posts from r/{subreddit} (timeframe: {timeframe}):\n"]
|
83
|
+
for i, post_container in enumerate(posts):
|
84
|
+
post = post_container.get("data", {})
|
85
|
+
title = post.get('title', 'No Title')
|
86
|
+
score = post.get('score', 0)
|
87
|
+
author = post.get('author', 'Unknown Author')
|
88
|
+
permalink = post.get('permalink', '')
|
89
|
+
full_url = f"https://www.reddit.com{permalink}" if permalink else "No Link"
|
90
|
+
|
91
|
+
result_lines.append(f"{i+1}. \"{title}\" by u/{author} (Score: {score})")
|
92
|
+
result_lines.append(f" Link: {full_url}")
|
93
|
+
|
94
|
+
return "\n".join(result_lines)
|
95
|
+
|
96
|
+
|
97
|
+
def search_subreddits(self, query: str, limit: int = 5, sort: str = "relevance") -> str:
|
98
|
+
"""Search for subreddits matching a query string.
|
18
99
|
|
19
100
|
Args:
|
20
|
-
|
101
|
+
query: The text to search for in subreddit names and descriptions.
|
102
|
+
limit: The maximum number of subreddits to return (default: 5, max: 100).
|
103
|
+
sort: The order of results. Valid options: 'relevance', 'activity' (default: 'relevance').
|
21
104
|
|
22
105
|
Returns:
|
23
|
-
A
|
106
|
+
A formatted string listing the found subreddits and their descriptions, or an error message.
|
24
107
|
"""
|
108
|
+
valid_sorts = ['relevance', 'activity']
|
109
|
+
if sort not in valid_sorts:
|
110
|
+
return f"Error: Invalid sort option '{sort}'. Please use one of: {', '.join(valid_sorts)}"
|
25
111
|
|
112
|
+
if not 1 <= limit <= 100:
|
113
|
+
return f"Error: Invalid limit '{limit}'. Please use a value between 1 and 100."
|
26
114
|
|
27
|
-
def list_tools(self):
|
28
|
-
return []
|
29
115
|
|
116
|
+
url = f"{self.base_api_url}/subreddits/search"
|
117
|
+
params = {
|
118
|
+
"q": query,
|
119
|
+
"limit": limit,
|
120
|
+
"sort": sort,
|
121
|
+
# Optionally include NSFW results? Defaulting to false for safety.
|
122
|
+
# "include_over_18": "false"
|
123
|
+
}
|
124
|
+
|
125
|
+
logger.info(f"Searching for subreddits matching '{query}' (limit: {limit}, sort: {sort})")
|
126
|
+
response = self._get(url, params=params)
|
127
|
+
|
128
|
+
data = response.json()
|
129
|
+
|
130
|
+
if "error" in data:
|
131
|
+
logger.error(f"Reddit API error during subreddit search: {data['error']} - {data.get('message', '')}")
|
132
|
+
return f"Error from Reddit API during search: {data['error']} - {data.get('message', '')}"
|
133
|
+
|
134
|
+
subreddits = data.get("data", {}).get("children", [])
|
135
|
+
|
136
|
+
if not subreddits:
|
137
|
+
return f"No subreddits found matching the query '{query}'."
|
138
|
+
|
139
|
+
result_lines = [f"Found {len(subreddits)} subreddits matching '{query}' (sorted by {sort}):\n"]
|
140
|
+
for i, sub_container in enumerate(subreddits):
|
141
|
+
sub_data = sub_container.get("data", {})
|
142
|
+
display_name = sub_data.get('display_name', 'N/A') # e.g., 'python'
|
143
|
+
title = sub_data.get('title', 'No Title') # Often the same as display_name or slightly longer
|
144
|
+
subscribers = sub_data.get('subscribers', 0)
|
145
|
+
# Use public_description if available, fallback to title
|
146
|
+
description = sub_data.get('public_description', '').strip() or title
|
147
|
+
|
148
|
+
# Format subscriber count nicely
|
149
|
+
subscriber_str = f"{subscribers:,}" if subscribers else "Unknown"
|
150
|
+
|
151
|
+
result_lines.append(f"{i+1}. r/{display_name} ({subscriber_str} subscribers)")
|
152
|
+
if description:
|
153
|
+
result_lines.append(f" Description: {description}")
|
154
|
+
|
155
|
+
return "\n".join(result_lines)
|
156
|
+
|
157
|
+
def get_post_flairs(self, subreddit: str):
|
158
|
+
"""Retrieve the list of available post flairs for a specific subreddit.
|
159
|
+
|
160
|
+
Args:
|
161
|
+
subreddit: The name of the subreddit (e.g., 'python', 'worldnews') without the 'r/'.
|
162
|
+
|
163
|
+
Returns:
|
164
|
+
A list of dictionaries containing flair details, or an error message.
|
165
|
+
"""
|
166
|
+
|
167
|
+
url = f"{self.base_api_url}/r/{subreddit}/api/link_flair_v2"
|
168
|
+
|
169
|
+
logger.info(f"Fetching post flairs for subreddit: r/{subreddit}")
|
170
|
+
response = self._get(url)
|
171
|
+
|
172
|
+
flairs = response.json()
|
173
|
+
if not flairs:
|
174
|
+
return f"No post flairs available for r/{subreddit}."
|
175
|
+
|
176
|
+
return flairs
|
177
|
+
|
178
|
+
def create_post(self, subreddit: str, title: str, kind: str = "self", text: str = None, url: str = None, flair_id: str = None):
|
179
|
+
"""Create a new post in a specified subreddit.
|
180
|
+
|
181
|
+
Args:
|
182
|
+
subreddit: The name of the subreddit (e.g., 'python', 'worldnews') without the 'r/'.
|
183
|
+
title: The title of the post.
|
184
|
+
kind: The type of post; either 'self' (text post) or 'link' (link or image post).
|
185
|
+
text: The text content of the post; required if kind is 'self'.
|
186
|
+
url: The URL of the link or image; required if kind is 'link'.
|
187
|
+
For image posts to be displayed correctly, the URL must directly point to an image file
|
188
|
+
and end with a valid image extension (e.g., .jpg, .png, or .gif).
|
189
|
+
Note that .gif support can be inconsistent.
|
190
|
+
flair_id: The ID of the flair to assign to the post.
|
191
|
+
|
192
|
+
Returns:
|
193
|
+
The JSON response from the Reddit API, or an error message as a string.
|
194
|
+
If the reddit api returns an error within the json response, that error will be returned as a string.
|
195
|
+
"""
|
196
|
+
|
197
|
+
if kind not in ["self", "link"]:
|
198
|
+
raise ValueError("Invalid post kind. Must be one of 'self' or 'link'.")
|
199
|
+
|
200
|
+
if kind == "self" and not text:
|
201
|
+
raise ValueError("Text content is required for text posts.")
|
202
|
+
if kind == "link" and not url:
|
203
|
+
raise ValueError("URL is required for link posts (including images).")
|
204
|
+
|
205
|
+
data = {
|
206
|
+
"sr": subreddit,
|
207
|
+
"title": title,
|
208
|
+
"kind": kind,
|
209
|
+
"text": text,
|
210
|
+
"url": url,
|
211
|
+
"flair_id": flair_id,
|
212
|
+
}
|
213
|
+
data = {k: v for k, v in data.items() if v is not None}
|
214
|
+
|
215
|
+
url_api = f"{self.base_api_url}/api/submit"
|
216
|
+
logger.info(f"Submitting a new post to r/{subreddit}")
|
217
|
+
response = self._post(url_api, data=data)
|
218
|
+
response_json = response.json()
|
219
|
+
|
220
|
+
# Check for Reddit API errors in the response
|
221
|
+
if response_json and "json" in response_json and "errors" in response_json["json"]:
|
222
|
+
errors = response_json["json"]["errors"]
|
223
|
+
if errors:
|
224
|
+
error_message = ", ".join([f"{code}: {message}" for code, message in errors])
|
225
|
+
return f"Reddit API error: {error_message}"
|
226
|
+
|
227
|
+
return response_json
|
228
|
+
|
229
|
+
def get_comment_by_id(self, comment_id: str) -> dict:
|
230
|
+
"""
|
231
|
+
Retrieve a specific Reddit comment by its full ID (t1_commentid).
|
232
|
+
|
233
|
+
Args:
|
234
|
+
comment_id: The full unique ID of the comment (e.g., 't1_abcdef').
|
235
|
+
|
236
|
+
Returns:
|
237
|
+
A dictionary containing the comment data, or an error message if retrieval fails.
|
238
|
+
"""
|
239
|
+
|
240
|
+
# Define the endpoint URL
|
241
|
+
url = f"https://oauth.reddit.com/api/info.json?id={comment_id}"
|
242
|
+
|
243
|
+
# Make the GET request to the Reddit API
|
244
|
+
|
245
|
+
response = self._get(url)
|
246
|
+
|
247
|
+
data = response.json()
|
248
|
+
comments = data.get("data", {}).get("children", [])
|
249
|
+
if comments:
|
250
|
+
return comments[0]["data"]
|
251
|
+
else:
|
252
|
+
return {"error": "Comment not found."}
|
253
|
+
|
254
|
+
def post_comment(self, parent_id: str, text: str) -> dict:
|
255
|
+
"""
|
256
|
+
Post a comment to a Reddit post or another comment.
|
257
|
+
|
258
|
+
Args:
|
259
|
+
parent_id: The full ID of the parent comment or post (e.g., 't3_abc123' for a post, 't1_def456' for a comment).
|
260
|
+
text: The text content of the comment.
|
261
|
+
|
262
|
+
Returns:
|
263
|
+
A dictionary containing the response from the Reddit API, or an error message if posting fails.
|
264
|
+
"""
|
265
|
+
|
266
|
+
url = f"{self.base_api_url}/api/comment"
|
267
|
+
data = {
|
268
|
+
"parent": parent_id,
|
269
|
+
"text": text,
|
270
|
+
}
|
271
|
+
|
272
|
+
logger.info(f"Posting comment to {parent_id}")
|
273
|
+
response = self._post(url, data=data)
|
274
|
+
|
275
|
+
return response.json()
|
276
|
+
|
277
|
+
def edit_content(self, content_id: str, text: str) -> dict:
|
278
|
+
"""
|
279
|
+
Edit the text content of a Reddit post or comment.
|
280
|
+
|
281
|
+
Args:
|
282
|
+
content_id: The full ID of the content to edit (e.g., 't3_abc123' for a post, 't1_def456' for a comment).
|
283
|
+
text: The new text content.
|
284
|
+
|
285
|
+
Returns:
|
286
|
+
A dictionary containing the response from the Reddit API, or an error message if editing fails.
|
287
|
+
"""
|
288
|
+
|
289
|
+
url = f"{self.base_api_url}/api/editusertext"
|
290
|
+
data = {
|
291
|
+
"thing_id": content_id,
|
292
|
+
"text": text,
|
293
|
+
}
|
294
|
+
|
295
|
+
logger.info(f"Editing content {content_id}")
|
296
|
+
response = self._post(url, data=data)
|
297
|
+
|
298
|
+
return response.json()
|
299
|
+
|
300
|
+
|
301
|
+
def delete_content(self, content_id: str) -> dict:
|
302
|
+
"""
|
303
|
+
Delete a Reddit post or comment.
|
304
|
+
|
305
|
+
Args:
|
306
|
+
content_id: The full ID of the content to delete (e.g., 't3_abc123' for a post, 't1_def456' for a comment).
|
307
|
+
|
308
|
+
Returns:
|
309
|
+
A dictionary containing the response from the Reddit API, or an error message if deletion fails.
|
310
|
+
"""
|
311
|
+
|
312
|
+
url = f"{self.base_api_url}/api/del"
|
313
|
+
data = {
|
314
|
+
"id": content_id,
|
315
|
+
}
|
316
|
+
|
317
|
+
logger.info(f"Deleting content {content_id}")
|
318
|
+
response = self._post(url, data=data)
|
319
|
+
response.raise_for_status()
|
320
|
+
|
321
|
+
# Reddit's delete endpoint returns an empty response on success.
|
322
|
+
# We'll just return a success message.
|
323
|
+
return {"message": f"Content {content_id} deleted successfully."}
|
324
|
+
|
325
|
+
def list_tools(self):
|
326
|
+
return [
|
327
|
+
self.get_subreddit_posts, self.search_subreddits, self.get_post_flairs, self.create_post,
|
328
|
+
self.get_comment_by_id, self.post_comment, self.edit_content, self.delete_content
|
329
|
+
]
|
agentr/cli.py
CHANGED
@@ -59,6 +59,8 @@ def install(app_name: str):
|
|
59
59
|
|
60
60
|
with open(config_path, 'r') as f:
|
61
61
|
config = json.load(f)
|
62
|
+
if 'mcpServers' not in config:
|
63
|
+
config['mcpServers'] = {}
|
62
64
|
config['mcpServers']['agentr'] = {
|
63
65
|
"command": "uvx",
|
64
66
|
"args": ["agentr@latest", "run"],
|
@@ -66,6 +68,35 @@ def install(app_name: str):
|
|
66
68
|
"AGENTR_API_KEY": api_key
|
67
69
|
}
|
68
70
|
}
|
71
|
+
with open(config_path, 'w') as f:
|
72
|
+
json.dump(config, f, indent=4)
|
73
|
+
typer.echo("App installed successfully")
|
74
|
+
elif app_name == "cursor":
|
75
|
+
typer.echo(f"Installing mcp server for: {app_name}")
|
76
|
+
|
77
|
+
# Set up Cursor config path
|
78
|
+
config_path = Path.home() / ".cursor/mcp.json"
|
79
|
+
|
80
|
+
# Create config directory if it doesn't exist
|
81
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
82
|
+
|
83
|
+
# Create or load existing config
|
84
|
+
if config_path.exists():
|
85
|
+
with open(config_path, 'r') as f:
|
86
|
+
config = json.load(f)
|
87
|
+
else:
|
88
|
+
config = {}
|
89
|
+
|
90
|
+
if 'mcpServers' not in config:
|
91
|
+
config['mcpServers'] = {}
|
92
|
+
config['mcpServers']['agentr'] = {
|
93
|
+
"command": "uvx",
|
94
|
+
"args": ["agentr@latest", "run"],
|
95
|
+
"env": {
|
96
|
+
"AGENTR_API_KEY": api_key
|
97
|
+
}
|
98
|
+
}
|
99
|
+
|
69
100
|
with open(config_path, 'w') as f:
|
70
101
|
json.dump(config, f, indent=4)
|
71
102
|
typer.echo("App installed successfully")
|
agentr/integration.py
CHANGED
@@ -1,8 +1,8 @@
|
|
1
1
|
from abc import ABC, abstractmethod
|
2
2
|
import os
|
3
|
-
import sys
|
4
3
|
|
5
4
|
from loguru import logger
|
5
|
+
from agentr.exceptions import NotAuthorizedError
|
6
6
|
from agentr.store import Store
|
7
7
|
import httpx
|
8
8
|
|
@@ -14,21 +14,53 @@ Supported integrations:
|
|
14
14
|
- API Key Integration
|
15
15
|
"""
|
16
16
|
|
17
|
+
|
17
18
|
class Integration(ABC):
|
19
|
+
"""Abstract base class for handling application integrations and authentication.
|
20
|
+
|
21
|
+
This class defines the interface for different types of integrations that handle
|
22
|
+
authentication and authorization with external services.
|
23
|
+
|
24
|
+
Args:
|
25
|
+
name: The name identifier for this integration
|
26
|
+
store: Optional Store instance for persisting credentials and other data
|
27
|
+
|
28
|
+
Attributes:
|
29
|
+
name: The name identifier for this integration
|
30
|
+
store: Store instance for persisting credentials and other data
|
31
|
+
"""
|
18
32
|
def __init__(self, name: str, store: Store = None):
|
19
33
|
self.name = name
|
20
34
|
self.store = store
|
21
35
|
|
22
36
|
@abstractmethod
|
23
37
|
def authorize(self):
|
38
|
+
"""Authorize the integration.
|
39
|
+
|
40
|
+
Returns:
|
41
|
+
str: Authorization URL.
|
42
|
+
"""
|
24
43
|
pass
|
25
44
|
|
26
45
|
@abstractmethod
|
27
46
|
def get_credentials(self):
|
47
|
+
"""Get credentials for the integration.
|
48
|
+
|
49
|
+
Returns:
|
50
|
+
dict: Credentials for the integration.
|
51
|
+
|
52
|
+
Raises:
|
53
|
+
NotAuthorizedError: If credentials are not found.
|
54
|
+
"""
|
28
55
|
pass
|
29
56
|
|
30
57
|
@abstractmethod
|
31
58
|
def set_credentials(self, credentials: dict):
|
59
|
+
"""Set credentials for the integration.
|
60
|
+
|
61
|
+
Args:
|
62
|
+
credentials: Credentials for the integration.
|
63
|
+
"""
|
32
64
|
pass
|
33
65
|
|
34
66
|
class ApiKeyIntegration(Integration):
|
@@ -46,8 +78,19 @@ class ApiKeyIntegration(Integration):
|
|
46
78
|
return {"text": "Please configure the environment variable {self.name}_API_KEY"}
|
47
79
|
|
48
80
|
|
49
|
-
|
50
81
|
class AgentRIntegration(Integration):
|
82
|
+
"""Integration class for AgentR API authentication and authorization.
|
83
|
+
|
84
|
+
This class handles API key authentication and OAuth authorization flow for AgentR services.
|
85
|
+
|
86
|
+
Args:
|
87
|
+
name (str): Name of the integration
|
88
|
+
api_key (str, optional): AgentR API key. If not provided, will look for AGENTR_API_KEY env var
|
89
|
+
**kwargs: Additional keyword arguments passed to parent Integration class
|
90
|
+
|
91
|
+
Raises:
|
92
|
+
ValueError: If no API key is provided or found in environment variables
|
93
|
+
"""
|
51
94
|
def __init__(self, name: str, api_key: str = None, **kwargs):
|
52
95
|
super().__init__(name, **kwargs)
|
53
96
|
self.api_key = api_key or os.getenv("AGENTR_API_KEY")
|
@@ -56,12 +99,32 @@ class AgentRIntegration(Integration):
|
|
56
99
|
raise ValueError("AgentR API key required - get one at https://agentr.dev")
|
57
100
|
self.base_url = os.getenv("AGENTR_BASE_URL", "https://api.agentr.dev")
|
58
101
|
|
59
|
-
|
60
102
|
def set_credentials(self, credentials: dict| None = None):
|
103
|
+
"""Set credentials for the integration.
|
104
|
+
|
105
|
+
This method is not implemented for AgentR integration. Instead it redirects to the authorize flow.
|
106
|
+
|
107
|
+
Args:
|
108
|
+
credentials (dict | None, optional): Credentials dict (not used). Defaults to None.
|
109
|
+
|
110
|
+
Returns:
|
111
|
+
str: Authorization URL from authorize() method
|
112
|
+
"""
|
61
113
|
return self.authorize()
|
62
114
|
# raise NotImplementedError("AgentR Integration does not support setting credentials. Visit the authorize url to set credentials.")
|
63
115
|
|
64
116
|
def get_credentials(self):
|
117
|
+
"""Get credentials for the integration from the AgentR API.
|
118
|
+
|
119
|
+
Makes API request to retrieve stored credentials for this integration.
|
120
|
+
|
121
|
+
Returns:
|
122
|
+
dict: Credentials data from API response
|
123
|
+
|
124
|
+
Raises:
|
125
|
+
NotAuthorizedError: If credentials are not found (404 response)
|
126
|
+
HTTPError: For other API errors
|
127
|
+
"""
|
65
128
|
response = httpx.get(
|
66
129
|
f"{self.base_url}/api/{self.name}/credentials/",
|
67
130
|
headers={
|
@@ -69,11 +132,24 @@ class AgentRIntegration(Integration):
|
|
69
132
|
"X-API-KEY": self.api_key
|
70
133
|
}
|
71
134
|
)
|
135
|
+
if response.status_code == 404:
|
136
|
+
action = self.authorize()
|
137
|
+
raise NotAuthorizedError(action)
|
72
138
|
response.raise_for_status()
|
73
139
|
data = response.json()
|
74
140
|
return data
|
75
141
|
|
76
142
|
def authorize(self):
|
143
|
+
"""Get authorization URL for the integration.
|
144
|
+
|
145
|
+
Makes API request to get OAuth authorization URL.
|
146
|
+
|
147
|
+
Returns:
|
148
|
+
str: Message containing authorization URL
|
149
|
+
|
150
|
+
Raises:
|
151
|
+
HTTPError: If API request fails
|
152
|
+
"""
|
77
153
|
response = httpx.get(
|
78
154
|
f"{self.base_url}/api/{self.name}/authorize/",
|
79
155
|
headers={
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# Integrations
|
2
|
+
|
3
|
+
This package provides integration classes for handling authentication and authorization with external services.
|
4
|
+
|
5
|
+
## Overview
|
6
|
+
|
7
|
+
An Integration defines how an application authenticates and authorizes with a service provider. The base `Integration` class provides an interface that all integrations must implement.
|
8
|
+
|
9
|
+
## Supported Integrations
|
10
|
+
|
11
|
+
### AgentR Integration
|
12
|
+
The `AgentRIntegration` class handles OAuth-based authentication flow with the AgentR API. It requires an API key which can be obtained from [agentr.dev](https://agentr.dev).
|
13
|
+
|
14
|
+
### API Key Integration
|
15
|
+
The `ApiKeyIntegration` class provides a simple API key based authentication mechanism. API keys are configured via environment variables.
|
16
|
+
|
17
|
+
## Usage
|
18
|
+
|
19
|
+
Each integration implements three key methods:
|
20
|
+
|
21
|
+
- `authorize()` - Initiates the authorization flow
|
22
|
+
- `get_credentials()` - Retrieves stored credentials
|
23
|
+
- `set_credentials()` - Stores new credentials
|
24
|
+
|
25
|
+
See the individual integration classes for specific usage details.
|
@@ -0,0 +1,87 @@
|
|
1
|
+
from agentr.integrations.base import Integration
|
2
|
+
import os
|
3
|
+
import httpx
|
4
|
+
from loguru import logger
|
5
|
+
from agentr.exceptions import NotAuthorizedError
|
6
|
+
|
7
|
+
class AgentRIntegration(Integration):
|
8
|
+
"""Integration class for AgentR API authentication and authorization.
|
9
|
+
|
10
|
+
This class handles API key authentication and OAuth authorization flow for AgentR services.
|
11
|
+
|
12
|
+
Args:
|
13
|
+
name (str): Name of the integration
|
14
|
+
api_key (str, optional): AgentR API key. If not provided, will look for AGENTR_API_KEY env var
|
15
|
+
**kwargs: Additional keyword arguments passed to parent Integration class
|
16
|
+
|
17
|
+
Raises:
|
18
|
+
ValueError: If no API key is provided or found in environment variables
|
19
|
+
"""
|
20
|
+
def __init__(self, name: str, api_key: str = None, **kwargs):
|
21
|
+
super().__init__(name, **kwargs)
|
22
|
+
self.api_key = api_key or os.getenv("AGENTR_API_KEY")
|
23
|
+
if not self.api_key:
|
24
|
+
logger.error("API key for AgentR is missing. Please visit https://agentr.dev to create an API key, then set it as AGENTR_API_KEY environment variable.")
|
25
|
+
raise ValueError("AgentR API key required - get one at https://agentr.dev")
|
26
|
+
self.base_url = os.getenv("AGENTR_BASE_URL", "https://api.agentr.dev")
|
27
|
+
|
28
|
+
def set_credentials(self, credentials: dict| None = None):
|
29
|
+
"""Set credentials for the integration.
|
30
|
+
|
31
|
+
This method is not implemented for AgentR integration. Instead it redirects to the authorize flow.
|
32
|
+
|
33
|
+
Args:
|
34
|
+
credentials (dict | None, optional): Credentials dict (not used). Defaults to None.
|
35
|
+
|
36
|
+
Returns:
|
37
|
+
str: Authorization URL from authorize() method
|
38
|
+
"""
|
39
|
+
return self.authorize()
|
40
|
+
# raise NotImplementedError("AgentR Integration does not support setting credentials. Visit the authorize url to set credentials.")
|
41
|
+
|
42
|
+
def get_credentials(self):
|
43
|
+
"""Get credentials for the integration from the AgentR API.
|
44
|
+
|
45
|
+
Makes API request to retrieve stored credentials for this integration.
|
46
|
+
|
47
|
+
Returns:
|
48
|
+
dict: Credentials data from API response
|
49
|
+
|
50
|
+
Raises:
|
51
|
+
NotAuthorizedError: If credentials are not found (404 response)
|
52
|
+
HTTPError: For other API errors
|
53
|
+
"""
|
54
|
+
response = httpx.get(
|
55
|
+
f"{self.base_url}/api/{self.name}/credentials/",
|
56
|
+
headers={
|
57
|
+
"accept": "application/json",
|
58
|
+
"X-API-KEY": self.api_key
|
59
|
+
}
|
60
|
+
)
|
61
|
+
if response.status_code == 404:
|
62
|
+
action = self.authorize()
|
63
|
+
raise NotAuthorizedError(action)
|
64
|
+
response.raise_for_status()
|
65
|
+
data = response.json()
|
66
|
+
return data
|
67
|
+
|
68
|
+
def authorize(self):
|
69
|
+
"""Get authorization URL for the integration.
|
70
|
+
|
71
|
+
Makes API request to get OAuth authorization URL.
|
72
|
+
|
73
|
+
Returns:
|
74
|
+
str: Message containing authorization URL
|
75
|
+
|
76
|
+
Raises:
|
77
|
+
HTTPError: If API request fails
|
78
|
+
"""
|
79
|
+
response = httpx.get(
|
80
|
+
f"{self.base_url}/api/{self.name}/authorize/",
|
81
|
+
headers={
|
82
|
+
"X-API-KEY": self.api_key
|
83
|
+
}
|
84
|
+
)
|
85
|
+
response.raise_for_status()
|
86
|
+
url = response.json()
|
87
|
+
return f"Please authorize the application by clicking the link {url}"
|
@@ -0,0 +1,16 @@
|
|
1
|
+
from agentr.integrations.base import Integration
|
2
|
+
from agentr.store import Store
|
3
|
+
|
4
|
+
class ApiKeyIntegration(Integration):
|
5
|
+
def __init__(self, name: str, store: Store = None, **kwargs):
|
6
|
+
super().__init__(name, store, **kwargs)
|
7
|
+
|
8
|
+
def get_credentials(self):
|
9
|
+
credentials = self.store.get(self.name)
|
10
|
+
return credentials
|
11
|
+
|
12
|
+
def set_credentials(self, credentials: dict):
|
13
|
+
self.store.set(self.name, credentials)
|
14
|
+
|
15
|
+
def authorize(self):
|
16
|
+
return {"text": "Please configure the environment variable {self.name}_API_KEY"}
|