mindroom 0.0.0__py3-none-any.whl → 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.
Files changed (155) hide show
  1. mindroom/__init__.py +3 -0
  2. mindroom/agent_prompts.py +963 -0
  3. mindroom/agents.py +248 -0
  4. mindroom/ai.py +421 -0
  5. mindroom/api/__init__.py +1 -0
  6. mindroom/api/credentials.py +137 -0
  7. mindroom/api/google_integration.py +355 -0
  8. mindroom/api/google_tools_helper.py +40 -0
  9. mindroom/api/homeassistant_integration.py +421 -0
  10. mindroom/api/integrations.py +189 -0
  11. mindroom/api/main.py +506 -0
  12. mindroom/api/matrix_operations.py +219 -0
  13. mindroom/api/tools.py +94 -0
  14. mindroom/background_tasks.py +87 -0
  15. mindroom/bot.py +2470 -0
  16. mindroom/cli.py +86 -0
  17. mindroom/commands.py +377 -0
  18. mindroom/config.py +343 -0
  19. mindroom/config_commands.py +324 -0
  20. mindroom/config_confirmation.py +411 -0
  21. mindroom/constants.py +52 -0
  22. mindroom/credentials.py +146 -0
  23. mindroom/credentials_sync.py +134 -0
  24. mindroom/custom_tools/__init__.py +8 -0
  25. mindroom/custom_tools/config_manager.py +765 -0
  26. mindroom/custom_tools/gmail.py +92 -0
  27. mindroom/custom_tools/google_calendar.py +92 -0
  28. mindroom/custom_tools/google_sheets.py +92 -0
  29. mindroom/custom_tools/homeassistant.py +341 -0
  30. mindroom/error_handling.py +35 -0
  31. mindroom/file_watcher.py +49 -0
  32. mindroom/interactive.py +313 -0
  33. mindroom/logging_config.py +207 -0
  34. mindroom/matrix/__init__.py +1 -0
  35. mindroom/matrix/client.py +782 -0
  36. mindroom/matrix/event_info.py +173 -0
  37. mindroom/matrix/identity.py +149 -0
  38. mindroom/matrix/large_messages.py +267 -0
  39. mindroom/matrix/mentions.py +141 -0
  40. mindroom/matrix/message_builder.py +94 -0
  41. mindroom/matrix/message_content.py +209 -0
  42. mindroom/matrix/presence.py +178 -0
  43. mindroom/matrix/rooms.py +311 -0
  44. mindroom/matrix/state.py +77 -0
  45. mindroom/matrix/typing.py +91 -0
  46. mindroom/matrix/users.py +217 -0
  47. mindroom/memory/__init__.py +21 -0
  48. mindroom/memory/config.py +137 -0
  49. mindroom/memory/functions.py +396 -0
  50. mindroom/py.typed +0 -0
  51. mindroom/response_tracker.py +128 -0
  52. mindroom/room_cleanup.py +139 -0
  53. mindroom/routing.py +107 -0
  54. mindroom/scheduling.py +758 -0
  55. mindroom/stop.py +207 -0
  56. mindroom/streaming.py +203 -0
  57. mindroom/teams.py +749 -0
  58. mindroom/thread_utils.py +318 -0
  59. mindroom/tools/__init__.py +520 -0
  60. mindroom/tools/agentql.py +64 -0
  61. mindroom/tools/airflow.py +57 -0
  62. mindroom/tools/apify.py +49 -0
  63. mindroom/tools/arxiv.py +64 -0
  64. mindroom/tools/aws_lambda.py +41 -0
  65. mindroom/tools/aws_ses.py +57 -0
  66. mindroom/tools/baidusearch.py +87 -0
  67. mindroom/tools/brightdata.py +116 -0
  68. mindroom/tools/browserbase.py +62 -0
  69. mindroom/tools/cal_com.py +98 -0
  70. mindroom/tools/calculator.py +112 -0
  71. mindroom/tools/cartesia.py +84 -0
  72. mindroom/tools/composio.py +166 -0
  73. mindroom/tools/config_manager.py +44 -0
  74. mindroom/tools/confluence.py +73 -0
  75. mindroom/tools/crawl4ai.py +101 -0
  76. mindroom/tools/csv.py +104 -0
  77. mindroom/tools/custom_api.py +106 -0
  78. mindroom/tools/dalle.py +85 -0
  79. mindroom/tools/daytona.py +180 -0
  80. mindroom/tools/discord.py +81 -0
  81. mindroom/tools/docker.py +73 -0
  82. mindroom/tools/duckdb.py +124 -0
  83. mindroom/tools/duckduckgo.py +99 -0
  84. mindroom/tools/e2b.py +121 -0
  85. mindroom/tools/eleven_labs.py +77 -0
  86. mindroom/tools/email.py +74 -0
  87. mindroom/tools/exa.py +246 -0
  88. mindroom/tools/fal.py +50 -0
  89. mindroom/tools/file.py +80 -0
  90. mindroom/tools/financial_datasets_api.py +112 -0
  91. mindroom/tools/firecrawl.py +124 -0
  92. mindroom/tools/gemini.py +85 -0
  93. mindroom/tools/giphy.py +49 -0
  94. mindroom/tools/github.py +376 -0
  95. mindroom/tools/gmail.py +102 -0
  96. mindroom/tools/google_calendar.py +55 -0
  97. mindroom/tools/google_maps.py +112 -0
  98. mindroom/tools/google_sheets.py +86 -0
  99. mindroom/tools/googlesearch.py +83 -0
  100. mindroom/tools/groq.py +77 -0
  101. mindroom/tools/hackernews.py +54 -0
  102. mindroom/tools/jina.py +108 -0
  103. mindroom/tools/jira.py +70 -0
  104. mindroom/tools/linear.py +103 -0
  105. mindroom/tools/linkup.py +65 -0
  106. mindroom/tools/lumalabs.py +71 -0
  107. mindroom/tools/mem0.py +82 -0
  108. mindroom/tools/modelslabs.py +85 -0
  109. mindroom/tools/moviepy_video_tools.py +62 -0
  110. mindroom/tools/newspaper4k.py +63 -0
  111. mindroom/tools/openai.py +143 -0
  112. mindroom/tools/openweather.py +89 -0
  113. mindroom/tools/oxylabs.py +54 -0
  114. mindroom/tools/pandas.py +35 -0
  115. mindroom/tools/pubmed.py +64 -0
  116. mindroom/tools/python.py +120 -0
  117. mindroom/tools/reddit.py +155 -0
  118. mindroom/tools/replicate.py +56 -0
  119. mindroom/tools/resend.py +55 -0
  120. mindroom/tools/scrapegraph.py +87 -0
  121. mindroom/tools/searxng.py +120 -0
  122. mindroom/tools/serpapi.py +55 -0
  123. mindroom/tools/serper.py +81 -0
  124. mindroom/tools/shell.py +46 -0
  125. mindroom/tools/slack.py +80 -0
  126. mindroom/tools/sleep.py +38 -0
  127. mindroom/tools/spider.py +62 -0
  128. mindroom/tools/sql.py +138 -0
  129. mindroom/tools/tavily.py +104 -0
  130. mindroom/tools/telegram.py +54 -0
  131. mindroom/tools/todoist.py +103 -0
  132. mindroom/tools/trello.py +121 -0
  133. mindroom/tools/twilio.py +97 -0
  134. mindroom/tools/web_browser_tools.py +37 -0
  135. mindroom/tools/webex.py +63 -0
  136. mindroom/tools/website.py +45 -0
  137. mindroom/tools/whatsapp.py +81 -0
  138. mindroom/tools/wikipedia.py +45 -0
  139. mindroom/tools/x.py +97 -0
  140. mindroom/tools/yfinance.py +121 -0
  141. mindroom/tools/youtube.py +81 -0
  142. mindroom/tools/zendesk.py +62 -0
  143. mindroom/tools/zep.py +107 -0
  144. mindroom/tools/zoom.py +62 -0
  145. mindroom/tools_metadata.json +7643 -0
  146. mindroom/tools_metadata.py +220 -0
  147. mindroom/topic_generator.py +153 -0
  148. mindroom/voice_handler.py +266 -0
  149. mindroom-0.1.0.dist-info/METADATA +425 -0
  150. mindroom-0.1.0.dist-info/RECORD +152 -0
  151. {mindroom-0.0.0.dist-info → mindroom-0.1.0.dist-info}/WHEEL +1 -2
  152. mindroom-0.1.0.dist-info/entry_points.txt +2 -0
  153. mindroom-0.0.0.dist-info/METADATA +0 -24
  154. mindroom-0.0.0.dist-info/RECORD +0 -4
  155. mindroom-0.0.0.dist-info/top_level.txt +0 -1
@@ -0,0 +1,421 @@
1
+ """Home Assistant Integration for MindRoom.
2
+
3
+ This module provides OAuth2 integration with Home Assistant, supporting:
4
+ - Device control (lights, switches, climate, etc.)
5
+ - State monitoring (sensors, binary sensors)
6
+ - Scene activation
7
+ - Service calls
8
+ - Automation triggers
9
+
10
+ Uses the official Home Assistant REST API.
11
+ """
12
+
13
+ import os
14
+ from typing import Any
15
+ from urllib.parse import urljoin
16
+
17
+ import httpx
18
+ from fastapi import APIRouter, HTTPException, Request
19
+ from fastapi.responses import RedirectResponse
20
+ from pydantic import BaseModel
21
+
22
+ from mindroom.credentials import CredentialsManager
23
+
24
+ router = APIRouter(prefix="/api/homeassistant", tags=["homeassistant-integration"])
25
+
26
+ # Initialize credentials manager
27
+ creds_manager = CredentialsManager()
28
+
29
+ # OAuth scopes for Home Assistant
30
+ # Home Assistant doesn't use traditional OAuth scopes, but we request full API access
31
+ SCOPES: list[str] = []
32
+
33
+ # Get configuration from environment
34
+ BACKEND_PORT = os.getenv("BACKEND_PORT", "8765")
35
+ FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
36
+
37
+
38
+ class HomeAssistantStatus(BaseModel):
39
+ """Home Assistant integration status."""
40
+
41
+ connected: bool
42
+ instance_url: str | None = None
43
+ version: str | None = None
44
+ location_name: str | None = None
45
+ error: str | None = None
46
+ has_credentials: bool = False
47
+ entities_count: int = 0
48
+
49
+
50
+ class HomeAssistantAuthUrl(BaseModel):
51
+ """Home Assistant OAuth URL response."""
52
+
53
+ auth_url: str
54
+
55
+
56
+ class HomeAssistantConfig(BaseModel):
57
+ """Home Assistant configuration."""
58
+
59
+ instance_url: str
60
+ client_id: str | None = None
61
+ long_lived_token: str | None = None
62
+
63
+
64
+ def get_stored_config() -> dict[str, Any] | None:
65
+ """Get stored Home Assistant configuration."""
66
+ return creds_manager.load_credentials("homeassistant")
67
+
68
+
69
+ def save_config(config: dict[str, Any]) -> None:
70
+ """Save Home Assistant configuration."""
71
+ creds_manager.save_credentials("homeassistant", config)
72
+
73
+
74
+ async def test_connection(instance_url: str, token: str) -> dict[str, Any]:
75
+ """Test connection to Home Assistant."""
76
+ async with httpx.AsyncClient() as client:
77
+ try:
78
+ # Test API connection
79
+ response = await client.get(
80
+ urljoin(instance_url, "/api/"),
81
+ headers={"Authorization": f"Bearer {token}"},
82
+ timeout=10.0,
83
+ )
84
+
85
+ if response.status_code == 401:
86
+ raise HTTPException(status_code=401, detail="Invalid authentication token")
87
+ if response.status_code != 200:
88
+ raise HTTPException(
89
+ status_code=response.status_code,
90
+ detail=f"Failed to connect to Home Assistant: {response.text}",
91
+ )
92
+
93
+ api_info = response.json()
94
+
95
+ # Get config for more details
96
+ config_response = await client.get(
97
+ urljoin(instance_url, "/api/config"),
98
+ headers={"Authorization": f"Bearer {token}"},
99
+ timeout=10.0,
100
+ )
101
+
102
+ config_info = config_response.json() if config_response.status_code == 200 else {}
103
+
104
+ # Get states to count entities
105
+ states_response = await client.get(
106
+ urljoin(instance_url, "/api/states"),
107
+ headers={"Authorization": f"Bearer {token}"},
108
+ timeout=10.0,
109
+ )
110
+
111
+ entities = states_response.json() if states_response.status_code == 200 else []
112
+
113
+ return {
114
+ "message": api_info.get("message", "API running"),
115
+ "version": config_info.get("version", "unknown"),
116
+ "location_name": config_info.get("location_name", "Home"),
117
+ "entities_count": len(entities),
118
+ }
119
+
120
+ except httpx.TimeoutException as e:
121
+ raise HTTPException(
122
+ status_code=504,
123
+ detail="Connection timeout - check if the URL is correct and accessible",
124
+ ) from e
125
+ except httpx.RequestError as e:
126
+ raise HTTPException(
127
+ status_code=503,
128
+ detail=f"Connection error: {e!s}",
129
+ ) from e
130
+
131
+
132
+ @router.get("/status")
133
+ async def get_status() -> HomeAssistantStatus:
134
+ """Check Home Assistant integration status."""
135
+ config = get_stored_config()
136
+
137
+ if not config:
138
+ return HomeAssistantStatus(
139
+ connected=False,
140
+ has_credentials=False,
141
+ )
142
+
143
+ try:
144
+ # Test the connection
145
+ instance_url = config.get("instance_url")
146
+ token = config.get("access_token") or config.get("long_lived_token")
147
+
148
+ if not instance_url or not token:
149
+ return HomeAssistantStatus(
150
+ connected=False,
151
+ has_credentials=True,
152
+ error="Missing instance URL or token",
153
+ )
154
+
155
+ info = await test_connection(instance_url, token)
156
+
157
+ return HomeAssistantStatus(
158
+ connected=True,
159
+ instance_url=instance_url,
160
+ version=info.get("version"),
161
+ location_name=info.get("location_name"),
162
+ has_credentials=True,
163
+ entities_count=info.get("entities_count", 0),
164
+ )
165
+
166
+ except HTTPException as e:
167
+ return HomeAssistantStatus(
168
+ connected=False,
169
+ has_credentials=True,
170
+ error=e.detail,
171
+ )
172
+ except Exception as e:
173
+ return HomeAssistantStatus(
174
+ connected=False,
175
+ has_credentials=True,
176
+ error=str(e),
177
+ )
178
+
179
+
180
+ @router.post("/connect/oauth")
181
+ async def connect_oauth(config: HomeAssistantConfig) -> HomeAssistantAuthUrl:
182
+ """Start Home Assistant OAuth flow."""
183
+ if not config.instance_url:
184
+ raise HTTPException(
185
+ status_code=400,
186
+ detail="Home Assistant instance URL is required",
187
+ )
188
+
189
+ if not config.client_id:
190
+ raise HTTPException(
191
+ status_code=400,
192
+ detail="OAuth Client ID is required for OAuth flow",
193
+ )
194
+
195
+ # Build OAuth authorization URL
196
+ # Home Assistant OAuth2 flow: https://developers.home-assistant.io/docs/auth_api/
197
+ redirect_uri = f"{FRONTEND_URL}/homeassistant-callback"
198
+
199
+ auth_params = {
200
+ "client_id": config.client_id,
201
+ "redirect_uri": redirect_uri,
202
+ "response_type": "code",
203
+ }
204
+
205
+ # Build query string
206
+ query_string = "&".join(f"{k}={v}" for k, v in auth_params.items())
207
+ auth_url = f"{config.instance_url}/auth/authorize?{query_string}"
208
+
209
+ # Store config for callback
210
+ temp_config = {
211
+ "instance_url": config.instance_url,
212
+ "client_id": config.client_id,
213
+ "redirect_uri": redirect_uri,
214
+ }
215
+ save_config(temp_config)
216
+
217
+ return HomeAssistantAuthUrl(auth_url=auth_url)
218
+
219
+
220
+ @router.post("/connect/token")
221
+ async def connect_token(config: HomeAssistantConfig) -> dict[str, str]:
222
+ """Connect using a long-lived access token."""
223
+ if not config.instance_url:
224
+ raise HTTPException(
225
+ status_code=400,
226
+ detail="Home Assistant instance URL is required",
227
+ )
228
+
229
+ if not config.long_lived_token:
230
+ raise HTTPException(
231
+ status_code=400,
232
+ detail="Long-lived access token is required",
233
+ )
234
+
235
+ # Normalize the instance URL
236
+ instance_url = config.instance_url.rstrip("/")
237
+ if not instance_url.startswith(("http://", "https://")):
238
+ instance_url = f"http://{instance_url}"
239
+
240
+ # Test the connection
241
+ try:
242
+ await test_connection(instance_url, config.long_lived_token)
243
+ except HTTPException:
244
+ raise
245
+ except Exception as e:
246
+ raise HTTPException(
247
+ status_code=503,
248
+ detail=f"Failed to connect to Home Assistant: {e!s}",
249
+ ) from e
250
+
251
+ # Save configuration
252
+ save_config(
253
+ {
254
+ "instance_url": instance_url,
255
+ "long_lived_token": config.long_lived_token,
256
+ },
257
+ )
258
+
259
+ return {"status": "connected", "message": "Successfully connected to Home Assistant"}
260
+
261
+
262
+ @router.get("/callback")
263
+ async def callback(request: Request) -> RedirectResponse:
264
+ """Handle Home Assistant OAuth callback."""
265
+ # Get the authorization code from the callback
266
+ code = request.query_params.get("code")
267
+ if not code:
268
+ raise HTTPException(status_code=400, detail="No authorization code received")
269
+
270
+ # Get stored config
271
+ config = get_stored_config()
272
+ if not config:
273
+ raise HTTPException(status_code=503, detail="No configuration found")
274
+
275
+ instance_url = config.get("instance_url")
276
+ client_id = config.get("client_id")
277
+
278
+ if not all([instance_url, client_id]) or not isinstance(instance_url, str):
279
+ raise HTTPException(status_code=503, detail="Incomplete configuration")
280
+
281
+ try:
282
+ # Exchange code for access token
283
+ async with httpx.AsyncClient() as client:
284
+ token_response = await client.post(
285
+ urljoin(instance_url, "/auth/token"),
286
+ data={
287
+ "grant_type": "authorization_code",
288
+ "code": code,
289
+ "client_id": client_id,
290
+ },
291
+ timeout=10.0,
292
+ )
293
+
294
+ if token_response.status_code != 200:
295
+ raise HTTPException(
296
+ status_code=token_response.status_code,
297
+ detail=f"Failed to get access token: {token_response.text}",
298
+ )
299
+
300
+ token_data = token_response.json()
301
+
302
+ # Save the access token
303
+ save_config(
304
+ {
305
+ "instance_url": instance_url,
306
+ "client_id": client_id,
307
+ "access_token": token_data.get("access_token"),
308
+ "refresh_token": token_data.get("refresh_token"),
309
+ "expires_in": token_data.get("expires_in"),
310
+ },
311
+ )
312
+
313
+ # Redirect back to widget with success message
314
+ return RedirectResponse(url=f"{FRONTEND_URL}/?homeassistant=connected")
315
+
316
+ except httpx.RequestError as e:
317
+ raise HTTPException(status_code=503, detail=f"Failed to exchange code: {e!s}") from e
318
+
319
+
320
+ @router.post("/disconnect")
321
+ async def disconnect() -> dict[str, str]:
322
+ """Disconnect Home Assistant by removing stored tokens."""
323
+ try:
324
+ # Remove credentials using the manager
325
+ creds_manager.delete_credentials("homeassistant")
326
+ except Exception as e:
327
+ raise HTTPException(status_code=500, detail=f"Failed to disconnect: {e!s}") from e
328
+ else:
329
+ return {"status": "disconnected"}
330
+
331
+
332
+ @router.get("/entities")
333
+ async def get_entities(domain: str | None = None) -> list[dict[str, Any]]:
334
+ """Get Home Assistant entities."""
335
+ config = get_stored_config()
336
+ if not config:
337
+ raise HTTPException(status_code=401, detail="Not connected to Home Assistant")
338
+
339
+ instance_url = config.get("instance_url")
340
+ token = config.get("access_token") or config.get("long_lived_token")
341
+
342
+ if not instance_url or not token:
343
+ raise HTTPException(status_code=401, detail="Missing credentials")
344
+
345
+ try:
346
+ async with httpx.AsyncClient() as client:
347
+ response = await client.get(
348
+ urljoin(instance_url, "/api/states"),
349
+ headers={"Authorization": f"Bearer {token}"},
350
+ timeout=10.0,
351
+ )
352
+
353
+ if response.status_code != 200:
354
+ raise HTTPException(
355
+ status_code=response.status_code,
356
+ detail=f"Failed to get entities: {response.text}",
357
+ )
358
+
359
+ entities = response.json()
360
+
361
+ # Filter by domain if specified
362
+ if domain:
363
+ entities = [e for e in entities if e["entity_id"].startswith(f"{domain}.")]
364
+
365
+ # Simplify the response
366
+ return [
367
+ {
368
+ "entity_id": e["entity_id"],
369
+ "state": e["state"],
370
+ "attributes": e.get("attributes", {}),
371
+ "last_changed": e.get("last_changed"),
372
+ }
373
+ for e in entities
374
+ ]
375
+
376
+ except httpx.RequestError as e:
377
+ raise HTTPException(status_code=503, detail=f"Failed to get entities: {e!s}") from e
378
+
379
+
380
+ @router.post("/service")
381
+ async def call_service(
382
+ domain: str,
383
+ service: str,
384
+ entity_id: str | None = None,
385
+ data: dict[str, Any] | None = None,
386
+ ) -> dict[str, Any]:
387
+ """Call a Home Assistant service."""
388
+ config = get_stored_config()
389
+ if not config:
390
+ raise HTTPException(status_code=401, detail="Not connected to Home Assistant")
391
+
392
+ instance_url = config.get("instance_url")
393
+ token = config.get("access_token") or config.get("long_lived_token")
394
+
395
+ if not instance_url or not token:
396
+ raise HTTPException(status_code=401, detail="Missing credentials")
397
+
398
+ # Build service data
399
+ service_data = data or {}
400
+ if entity_id:
401
+ service_data["entity_id"] = entity_id
402
+
403
+ try:
404
+ async with httpx.AsyncClient() as client:
405
+ response = await client.post(
406
+ urljoin(instance_url, f"/api/services/{domain}/{service}"),
407
+ headers={"Authorization": f"Bearer {token}"},
408
+ json=service_data,
409
+ timeout=10.0,
410
+ )
411
+
412
+ if response.status_code not in (200, 201):
413
+ raise HTTPException(
414
+ status_code=response.status_code,
415
+ detail=f"Failed to call service: {response.text}",
416
+ )
417
+
418
+ return {"success": True, "message": f"Service {domain}.{service} called successfully"}
419
+
420
+ except httpx.RequestError as e:
421
+ raise HTTPException(status_code=503, detail=f"Failed to call service: {e!s}") from e
@@ -0,0 +1,189 @@
1
+ """Third-party service integrations API."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from fastapi import APIRouter, HTTPException
9
+ from fastapi.responses import RedirectResponse
10
+ from pydantic import BaseModel
11
+ from spotipy import Spotify, SpotifyOAuth # type: ignore[import-untyped]
12
+
13
+ import mindroom
14
+ from mindroom.credentials import CredentialsManager
15
+
16
+ router = APIRouter(prefix="/api/integrations", tags=["integrations"])
17
+
18
+ # Initialize credentials manager
19
+ creds_manager = CredentialsManager()
20
+
21
+
22
+ # Load tool metadata from the single source of truth
23
+ def get_tools_metadata() -> dict[str, Any]:
24
+ """Load tool metadata from JSON file."""
25
+ # Always use the package location to find tools_metadata.json
26
+ # This works regardless of how the package is installed or where it's run from
27
+ package_dir = Path(mindroom.__file__).parent
28
+ json_path = package_dir / "tools_metadata.json"
29
+
30
+ if not json_path.exists():
31
+ print(f"Warning: tools_metadata.json not found at {json_path}")
32
+ return {}
33
+
34
+ with json_path.open() as f:
35
+ data = json.load(f)
36
+ # Convert to dict keyed by tool name for easy lookup
37
+ return {tool["name"]: tool for tool in data.get("tools", [])}
38
+
39
+
40
+ class ServiceStatus(BaseModel):
41
+ """Service connection status."""
42
+
43
+ service: str
44
+ connected: bool
45
+ display_name: str
46
+ icon: str
47
+ category: str
48
+ requires_oauth: bool
49
+ requires_api_key: bool
50
+ details: dict[str, Any] | None = None
51
+ error: str | None = None
52
+
53
+
54
+ class ApiKeyRequest(BaseModel):
55
+ """API key configuration request."""
56
+
57
+ service: str
58
+ api_key: str
59
+ api_secret: str | None = None
60
+
61
+
62
+ def get_service_credentials(service: str) -> dict[str, Any]:
63
+ """Get stored credentials for a service."""
64
+ credentials = creds_manager.load_credentials(service)
65
+ return credentials if credentials else {}
66
+
67
+
68
+ def save_service_credentials(service: str, credentials: dict[str, Any]) -> None:
69
+ """Save service credentials."""
70
+ creds_manager.save_credentials(service, credentials)
71
+
72
+
73
+ @router.get("/{service}/status")
74
+ async def get_service_status(service: str) -> ServiceStatus:
75
+ """Get connection status for a specific service."""
76
+ # Get tool metadata from single source of truth
77
+ tools_metadata = get_tools_metadata()
78
+
79
+ if service not in tools_metadata:
80
+ raise HTTPException(status_code=404, detail=f"Unknown service: {service}")
81
+
82
+ tool = tools_metadata[service]
83
+ status = ServiceStatus(
84
+ service=service,
85
+ connected=False,
86
+ display_name=tool.get("display_name", service),
87
+ icon=tool.get("icon", "📦"),
88
+ category=tool.get("category", "other"),
89
+ requires_oauth=tool.get("setup_type") == "oauth",
90
+ requires_api_key=tool.get("setup_type") == "api_key",
91
+ )
92
+
93
+ creds = get_service_credentials(service)
94
+ if creds:
95
+ if service == "spotify":
96
+ status.connected = "access_token" in creds
97
+ if status.connected:
98
+ try:
99
+ # Try to get user info
100
+ sp = Spotify(auth=creds["access_token"])
101
+ user = sp.current_user()
102
+ status.details = {
103
+ "username": user["display_name"],
104
+ "email": user.get("email"),
105
+ "product": user.get("product"),
106
+ }
107
+ except Exception as e:
108
+ status.connected = False
109
+ status.error = str(e)
110
+ else:
111
+ status.connected = "api_key" in creds
112
+
113
+ return status
114
+
115
+
116
+ # Spotify
117
+ @router.post("/spotify/connect")
118
+ async def connect_spotify() -> dict[str, str]:
119
+ """Start Spotify OAuth flow."""
120
+ client_id = os.getenv("SPOTIFY_CLIENT_ID")
121
+ client_secret = os.getenv("SPOTIFY_CLIENT_SECRET")
122
+
123
+ if not client_id or not client_secret:
124
+ raise HTTPException(
125
+ status_code=500,
126
+ detail="Spotify OAuth not configured. Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables.",
127
+ )
128
+
129
+ sp_oauth = SpotifyOAuth(
130
+ client_id=client_id,
131
+ client_secret=client_secret,
132
+ redirect_uri="http://localhost:8000/api/integrations/spotify/callback",
133
+ scope="user-read-private user-read-email user-read-playback-state user-read-currently-playing user-top-read",
134
+ )
135
+
136
+ auth_url = sp_oauth.get_authorize_url()
137
+ return {"auth_url": auth_url}
138
+
139
+
140
+ @router.get("/spotify/callback")
141
+ async def spotify_callback(code: str) -> RedirectResponse:
142
+ """Handle Spotify OAuth callback."""
143
+ client_id = os.getenv("SPOTIFY_CLIENT_ID")
144
+ client_secret = os.getenv("SPOTIFY_CLIENT_SECRET")
145
+
146
+ if not client_id or not client_secret:
147
+ raise HTTPException(status_code=500, detail="Spotify OAuth not configured")
148
+
149
+ try:
150
+ sp_oauth = SpotifyOAuth(
151
+ client_id=client_id,
152
+ client_secret=client_secret,
153
+ redirect_uri="http://localhost:8000/api/integrations/spotify/callback",
154
+ )
155
+
156
+ token_info = sp_oauth.get_access_token(code)
157
+
158
+ # Get user info
159
+ sp = Spotify(auth=token_info["access_token"])
160
+ user = sp.current_user()
161
+
162
+ # Save credentials
163
+ credentials = {
164
+ "access_token": token_info["access_token"],
165
+ "refresh_token": token_info.get("refresh_token"),
166
+ "expires_at": token_info.get("expires_at"),
167
+ "username": user["display_name"],
168
+ }
169
+ save_service_credentials("spotify", credentials)
170
+
171
+ # Redirect back to widget
172
+ return RedirectResponse(url="http://localhost:5173/?spotify=connected")
173
+ except Exception as e:
174
+ raise HTTPException(status_code=500, detail=f"OAuth failed: {e!s}") from e
175
+
176
+
177
+ @router.post("/{service}/disconnect")
178
+ async def disconnect_service(service: str) -> dict[str, str]:
179
+ """Disconnect a service by removing stored credentials."""
180
+ # Get tool metadata from single source of truth
181
+ tools_metadata = get_tools_metadata()
182
+
183
+ if service not in tools_metadata:
184
+ raise HTTPException(status_code=404, detail=f"Unknown service: {service}")
185
+
186
+ # Delete credentials using the manager
187
+ creds_manager.delete_credentials(service)
188
+
189
+ return {"status": "disconnected"}