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.
- mindroom/__init__.py +3 -0
- mindroom/agent_prompts.py +963 -0
- mindroom/agents.py +248 -0
- mindroom/ai.py +421 -0
- mindroom/api/__init__.py +1 -0
- mindroom/api/credentials.py +137 -0
- mindroom/api/google_integration.py +355 -0
- mindroom/api/google_tools_helper.py +40 -0
- mindroom/api/homeassistant_integration.py +421 -0
- mindroom/api/integrations.py +189 -0
- mindroom/api/main.py +506 -0
- mindroom/api/matrix_operations.py +219 -0
- mindroom/api/tools.py +94 -0
- mindroom/background_tasks.py +87 -0
- mindroom/bot.py +2470 -0
- mindroom/cli.py +86 -0
- mindroom/commands.py +377 -0
- mindroom/config.py +343 -0
- mindroom/config_commands.py +324 -0
- mindroom/config_confirmation.py +411 -0
- mindroom/constants.py +52 -0
- mindroom/credentials.py +146 -0
- mindroom/credentials_sync.py +134 -0
- mindroom/custom_tools/__init__.py +8 -0
- mindroom/custom_tools/config_manager.py +765 -0
- mindroom/custom_tools/gmail.py +92 -0
- mindroom/custom_tools/google_calendar.py +92 -0
- mindroom/custom_tools/google_sheets.py +92 -0
- mindroom/custom_tools/homeassistant.py +341 -0
- mindroom/error_handling.py +35 -0
- mindroom/file_watcher.py +49 -0
- mindroom/interactive.py +313 -0
- mindroom/logging_config.py +207 -0
- mindroom/matrix/__init__.py +1 -0
- mindroom/matrix/client.py +782 -0
- mindroom/matrix/event_info.py +173 -0
- mindroom/matrix/identity.py +149 -0
- mindroom/matrix/large_messages.py +267 -0
- mindroom/matrix/mentions.py +141 -0
- mindroom/matrix/message_builder.py +94 -0
- mindroom/matrix/message_content.py +209 -0
- mindroom/matrix/presence.py +178 -0
- mindroom/matrix/rooms.py +311 -0
- mindroom/matrix/state.py +77 -0
- mindroom/matrix/typing.py +91 -0
- mindroom/matrix/users.py +217 -0
- mindroom/memory/__init__.py +21 -0
- mindroom/memory/config.py +137 -0
- mindroom/memory/functions.py +396 -0
- mindroom/py.typed +0 -0
- mindroom/response_tracker.py +128 -0
- mindroom/room_cleanup.py +139 -0
- mindroom/routing.py +107 -0
- mindroom/scheduling.py +758 -0
- mindroom/stop.py +207 -0
- mindroom/streaming.py +203 -0
- mindroom/teams.py +749 -0
- mindroom/thread_utils.py +318 -0
- mindroom/tools/__init__.py +520 -0
- mindroom/tools/agentql.py +64 -0
- mindroom/tools/airflow.py +57 -0
- mindroom/tools/apify.py +49 -0
- mindroom/tools/arxiv.py +64 -0
- mindroom/tools/aws_lambda.py +41 -0
- mindroom/tools/aws_ses.py +57 -0
- mindroom/tools/baidusearch.py +87 -0
- mindroom/tools/brightdata.py +116 -0
- mindroom/tools/browserbase.py +62 -0
- mindroom/tools/cal_com.py +98 -0
- mindroom/tools/calculator.py +112 -0
- mindroom/tools/cartesia.py +84 -0
- mindroom/tools/composio.py +166 -0
- mindroom/tools/config_manager.py +44 -0
- mindroom/tools/confluence.py +73 -0
- mindroom/tools/crawl4ai.py +101 -0
- mindroom/tools/csv.py +104 -0
- mindroom/tools/custom_api.py +106 -0
- mindroom/tools/dalle.py +85 -0
- mindroom/tools/daytona.py +180 -0
- mindroom/tools/discord.py +81 -0
- mindroom/tools/docker.py +73 -0
- mindroom/tools/duckdb.py +124 -0
- mindroom/tools/duckduckgo.py +99 -0
- mindroom/tools/e2b.py +121 -0
- mindroom/tools/eleven_labs.py +77 -0
- mindroom/tools/email.py +74 -0
- mindroom/tools/exa.py +246 -0
- mindroom/tools/fal.py +50 -0
- mindroom/tools/file.py +80 -0
- mindroom/tools/financial_datasets_api.py +112 -0
- mindroom/tools/firecrawl.py +124 -0
- mindroom/tools/gemini.py +85 -0
- mindroom/tools/giphy.py +49 -0
- mindroom/tools/github.py +376 -0
- mindroom/tools/gmail.py +102 -0
- mindroom/tools/google_calendar.py +55 -0
- mindroom/tools/google_maps.py +112 -0
- mindroom/tools/google_sheets.py +86 -0
- mindroom/tools/googlesearch.py +83 -0
- mindroom/tools/groq.py +77 -0
- mindroom/tools/hackernews.py +54 -0
- mindroom/tools/jina.py +108 -0
- mindroom/tools/jira.py +70 -0
- mindroom/tools/linear.py +103 -0
- mindroom/tools/linkup.py +65 -0
- mindroom/tools/lumalabs.py +71 -0
- mindroom/tools/mem0.py +82 -0
- mindroom/tools/modelslabs.py +85 -0
- mindroom/tools/moviepy_video_tools.py +62 -0
- mindroom/tools/newspaper4k.py +63 -0
- mindroom/tools/openai.py +143 -0
- mindroom/tools/openweather.py +89 -0
- mindroom/tools/oxylabs.py +54 -0
- mindroom/tools/pandas.py +35 -0
- mindroom/tools/pubmed.py +64 -0
- mindroom/tools/python.py +120 -0
- mindroom/tools/reddit.py +155 -0
- mindroom/tools/replicate.py +56 -0
- mindroom/tools/resend.py +55 -0
- mindroom/tools/scrapegraph.py +87 -0
- mindroom/tools/searxng.py +120 -0
- mindroom/tools/serpapi.py +55 -0
- mindroom/tools/serper.py +81 -0
- mindroom/tools/shell.py +46 -0
- mindroom/tools/slack.py +80 -0
- mindroom/tools/sleep.py +38 -0
- mindroom/tools/spider.py +62 -0
- mindroom/tools/sql.py +138 -0
- mindroom/tools/tavily.py +104 -0
- mindroom/tools/telegram.py +54 -0
- mindroom/tools/todoist.py +103 -0
- mindroom/tools/trello.py +121 -0
- mindroom/tools/twilio.py +97 -0
- mindroom/tools/web_browser_tools.py +37 -0
- mindroom/tools/webex.py +63 -0
- mindroom/tools/website.py +45 -0
- mindroom/tools/whatsapp.py +81 -0
- mindroom/tools/wikipedia.py +45 -0
- mindroom/tools/x.py +97 -0
- mindroom/tools/yfinance.py +121 -0
- mindroom/tools/youtube.py +81 -0
- mindroom/tools/zendesk.py +62 -0
- mindroom/tools/zep.py +107 -0
- mindroom/tools/zoom.py +62 -0
- mindroom/tools_metadata.json +7643 -0
- mindroom/tools_metadata.py +220 -0
- mindroom/topic_generator.py +153 -0
- mindroom/voice_handler.py +266 -0
- mindroom-0.1.0.dist-info/METADATA +425 -0
- mindroom-0.1.0.dist-info/RECORD +152 -0
- {mindroom-0.0.0.dist-info → mindroom-0.1.0.dist-info}/WHEEL +1 -2
- mindroom-0.1.0.dist-info/entry_points.txt +2 -0
- mindroom-0.0.0.dist-info/METADATA +0 -24
- mindroom-0.0.0.dist-info/RECORD +0 -4
- 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"}
|