mindroom 0.0.0__py3-none-any.whl → 0.1.1__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.1.dist-info/METADATA +425 -0
  150. mindroom-0.1.1.dist-info/RECORD +152 -0
  151. {mindroom-0.0.0.dist-info → mindroom-0.1.1.dist-info}/WHEEL +1 -2
  152. mindroom-0.1.1.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,411 @@
1
+ """Configuration change confirmation system using Matrix reactions with persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import UTC, datetime
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ import nio
10
+
11
+ from .logging_config import get_logger
12
+
13
+ if TYPE_CHECKING:
14
+ from .bot import AgentBot
15
+
16
+ logger = get_logger(__name__)
17
+
18
+ # Event type for pending config changes in Matrix state
19
+ PENDING_CONFIG_EVENT_TYPE = "com.mindroom.pending.config"
20
+
21
+ # Maximum age for pending confirmations (24 hours)
22
+ MAX_PENDING_AGE_HOURS = 24
23
+
24
+
25
+ @dataclass
26
+ class PendingConfigChange:
27
+ """Represents a pending configuration change awaiting confirmation."""
28
+
29
+ room_id: str
30
+ thread_id: str | None
31
+ config_path: str
32
+ old_value: Any
33
+ new_value: Any
34
+ requester: str # User who requested the change
35
+ created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
36
+
37
+ def is_expired(self) -> bool:
38
+ """Check if this pending change has expired."""
39
+ age = datetime.now(UTC) - self.created_at
40
+ return age.total_seconds() > MAX_PENDING_AGE_HOURS * 3600
41
+
42
+ def to_dict(self) -> dict[str, Any]:
43
+ """Convert to dictionary for Matrix state storage."""
44
+ return {
45
+ "room_id": self.room_id,
46
+ "thread_id": self.thread_id,
47
+ "config_path": self.config_path,
48
+ "old_value": self.old_value,
49
+ "new_value": self.new_value,
50
+ "requester": self.requester,
51
+ "created_at": self.created_at.isoformat(),
52
+ }
53
+
54
+ @classmethod
55
+ def from_dict(cls, data: dict[str, Any]) -> PendingConfigChange:
56
+ """Create from dictionary retrieved from Matrix state."""
57
+ # Parse the ISO format datetime
58
+ created_at = datetime.fromisoformat(data["created_at"])
59
+
60
+ return cls(
61
+ room_id=data["room_id"],
62
+ thread_id=data.get("thread_id"),
63
+ config_path=data["config_path"],
64
+ old_value=data["old_value"],
65
+ new_value=data["new_value"],
66
+ requester=data["requester"],
67
+ created_at=created_at,
68
+ )
69
+
70
+
71
+ # Track pending configuration changes by event_id
72
+ _pending_changes: dict[str, PendingConfigChange] = {}
73
+
74
+
75
+ def register_pending_change(
76
+ event_id: str,
77
+ room_id: str,
78
+ thread_id: str | None,
79
+ config_path: str,
80
+ old_value: Any, # noqa: ANN401
81
+ new_value: Any, # noqa: ANN401
82
+ requester: str,
83
+ ) -> None:
84
+ """Register a pending configuration change for confirmation.
85
+
86
+ Args:
87
+ event_id: The event ID of the confirmation message
88
+ room_id: The room ID
89
+ thread_id: Thread ID if in a thread
90
+ config_path: The configuration path being changed
91
+ old_value: The current value
92
+ new_value: The proposed new value
93
+ requester: User ID who requested the change
94
+
95
+ """
96
+ _pending_changes[event_id] = PendingConfigChange(
97
+ room_id=room_id,
98
+ thread_id=thread_id,
99
+ config_path=config_path,
100
+ old_value=old_value,
101
+ new_value=new_value,
102
+ requester=requester,
103
+ )
104
+ logger.info(
105
+ "Registered pending config change",
106
+ event_id=event_id,
107
+ path=config_path,
108
+ requester=requester,
109
+ )
110
+
111
+
112
+ def get_pending_change(event_id: str) -> PendingConfigChange | None:
113
+ """Get a pending configuration change by event ID.
114
+
115
+ Args:
116
+ event_id: The event ID of the confirmation message
117
+
118
+ Returns:
119
+ The pending change or None if not found
120
+
121
+ """
122
+ return _pending_changes.get(event_id)
123
+
124
+
125
+ def remove_pending_change(event_id: str) -> PendingConfigChange | None:
126
+ """Remove and return a pending configuration change.
127
+
128
+ Args:
129
+ event_id: The event ID of the confirmation message
130
+
131
+ Returns:
132
+ The removed pending change or None if not found
133
+
134
+ """
135
+ return _pending_changes.pop(event_id, None)
136
+
137
+
138
+ async def store_pending_change_in_matrix(
139
+ client: nio.AsyncClient,
140
+ event_id: str,
141
+ pending_change: PendingConfigChange,
142
+ ) -> None:
143
+ """Store pending config change in Matrix room state for persistence.
144
+
145
+ Args:
146
+ client: The Matrix client
147
+ event_id: The event ID of the confirmation message
148
+ pending_change: The pending configuration change
149
+
150
+ """
151
+ try:
152
+ response = await client.room_put_state(
153
+ room_id=pending_change.room_id,
154
+ event_type=PENDING_CONFIG_EVENT_TYPE,
155
+ content=pending_change.to_dict(),
156
+ state_key=event_id,
157
+ )
158
+
159
+ if isinstance(response, nio.RoomPutStateResponse):
160
+ logger.info(
161
+ "Stored pending config change in Matrix state",
162
+ event_id=event_id,
163
+ room_id=pending_change.room_id,
164
+ config_path=pending_change.config_path,
165
+ )
166
+ else:
167
+ logger.error(
168
+ "Failed to store pending config change in Matrix state",
169
+ event_id=event_id,
170
+ error=str(response),
171
+ )
172
+ except Exception:
173
+ logger.exception("Error storing pending config change in Matrix state")
174
+
175
+
176
+ async def remove_pending_change_from_matrix(
177
+ client: nio.AsyncClient,
178
+ room_id: str,
179
+ event_id: str,
180
+ ) -> None:
181
+ """Remove pending config change from Matrix room state.
182
+
183
+ Args:
184
+ client: The Matrix client
185
+ room_id: The room ID
186
+ event_id: The event ID of the confirmation message
187
+
188
+ """
189
+ try:
190
+ # To remove a state event, set it with empty content
191
+ response = await client.room_put_state(
192
+ room_id=room_id,
193
+ event_type=PENDING_CONFIG_EVENT_TYPE,
194
+ content={},
195
+ state_key=event_id,
196
+ )
197
+
198
+ if isinstance(response, nio.RoomPutStateResponse):
199
+ logger.info(
200
+ "Removed pending config change from Matrix state",
201
+ event_id=event_id,
202
+ room_id=room_id,
203
+ )
204
+ else:
205
+ logger.error(
206
+ "Failed to remove pending config change from Matrix state",
207
+ event_id=event_id,
208
+ error=str(response),
209
+ )
210
+ except Exception:
211
+ logger.exception("Error removing pending config change from Matrix state")
212
+
213
+
214
+ async def restore_pending_changes(client: nio.AsyncClient, room_id: str) -> int:
215
+ """Restore pending config changes from Matrix state after bot restart.
216
+
217
+ Args:
218
+ client: The Matrix client
219
+ room_id: The room ID to restore from
220
+
221
+ Returns:
222
+ Number of pending changes restored
223
+
224
+ """
225
+ try:
226
+ response = await client.room_get_state(room_id)
227
+ if not isinstance(response, nio.RoomGetStateResponse):
228
+ logger.warning(
229
+ "Failed to get room state for pending config changes",
230
+ room_id=room_id,
231
+ error=str(response),
232
+ )
233
+ return 0
234
+
235
+ restored_count = 0
236
+ expired_count = 0
237
+
238
+ for event in response.events:
239
+ if event.get("type") != PENDING_CONFIG_EVENT_TYPE:
240
+ continue
241
+
242
+ state_key = event.get("state_key")
243
+ content = event.get("content", {})
244
+
245
+ # Skip empty content (deleted state events)
246
+ if not content:
247
+ continue
248
+
249
+ try:
250
+ pending_change = PendingConfigChange.from_dict(content)
251
+
252
+ # Check if expired
253
+ if pending_change.is_expired():
254
+ logger.info(
255
+ "Skipping expired pending config change",
256
+ event_id=state_key,
257
+ created_at=pending_change.created_at,
258
+ )
259
+ # Remove from Matrix state
260
+ await remove_pending_change_from_matrix(client, room_id, state_key)
261
+ expired_count += 1
262
+ else:
263
+ # Restore to memory
264
+ _pending_changes[state_key] = pending_change
265
+ restored_count += 1
266
+ logger.info(
267
+ "Restored pending config change",
268
+ event_id=state_key,
269
+ config_path=pending_change.config_path,
270
+ requester=pending_change.requester,
271
+ )
272
+ except Exception:
273
+ logger.exception(
274
+ "Error restoring pending config change",
275
+ event_id=state_key,
276
+ )
277
+
278
+ if restored_count > 0 or expired_count > 0:
279
+ logger.info(
280
+ "Completed restoration of pending config changes",
281
+ room_id=room_id,
282
+ restored=restored_count,
283
+ expired=expired_count,
284
+ )
285
+
286
+ return restored_count # noqa: TRY300
287
+
288
+ except Exception:
289
+ logger.exception("Error restoring pending config changes from Matrix state")
290
+ return 0
291
+
292
+
293
+ def cleanup() -> None:
294
+ """Clean up when shutting down."""
295
+ _pending_changes.clear()
296
+
297
+
298
+ async def add_confirmation_reactions(client: nio.AsyncClient, room_id: str, event_id: str) -> None:
299
+ """Add confirmation reaction buttons to a config change message.
300
+
301
+ Args:
302
+ client: The Matrix client
303
+ room_id: The room ID
304
+ event_id: The event ID of the message to add reactions to
305
+
306
+ """
307
+ # Add ✅ reaction
308
+ confirm_response = await client.room_send(
309
+ room_id=room_id,
310
+ message_type="m.reaction",
311
+ content={
312
+ "m.relates_to": {
313
+ "rel_type": "m.annotation",
314
+ "event_id": event_id,
315
+ "key": "✅",
316
+ },
317
+ },
318
+ )
319
+ if not isinstance(confirm_response, nio.RoomSendResponse):
320
+ logger.warning("Failed to add confirm reaction", error=str(confirm_response))
321
+
322
+ # Add ❌ reaction
323
+ cancel_response = await client.room_send(
324
+ room_id=room_id,
325
+ message_type="m.reaction",
326
+ content={
327
+ "m.relates_to": {
328
+ "rel_type": "m.annotation",
329
+ "event_id": event_id,
330
+ "key": "❌",
331
+ },
332
+ },
333
+ )
334
+ if not isinstance(cancel_response, nio.RoomSendResponse):
335
+ logger.warning("Failed to add cancel reaction", error=str(cancel_response))
336
+
337
+
338
+ async def handle_confirmation_reaction(
339
+ bot: AgentBot,
340
+ room: nio.MatrixRoom,
341
+ event: nio.ReactionEvent,
342
+ pending_change: PendingConfigChange,
343
+ ) -> None:
344
+ """Handle reactions to config confirmation messages.
345
+
346
+ Args:
347
+ bot: The agent bot instance
348
+ room: The room the reaction occurred in
349
+ event: The reaction event
350
+ pending_change: The pending configuration change
351
+
352
+ """
353
+ # Only process reactions from the requester
354
+ if event.sender != pending_change.requester:
355
+ logger.debug(
356
+ "Ignoring config reaction from non-requester",
357
+ sender=event.sender,
358
+ requester=pending_change.requester,
359
+ )
360
+ return
361
+
362
+ # Don't process our own reactions
363
+ assert bot.client is not None
364
+ if event.sender == bot.client.user_id:
365
+ return
366
+
367
+ reaction_key = event.key
368
+
369
+ # Only handle ✅ and ❌ reactions
370
+ if reaction_key not in ["✅", "❌"]:
371
+ return
372
+
373
+ # Remove the pending change from memory and Matrix state
374
+ remove_pending_change(event.reacts_to)
375
+ await remove_pending_change_from_matrix(
376
+ bot.client,
377
+ pending_change.room_id,
378
+ event.reacts_to,
379
+ )
380
+
381
+ if reaction_key == "✅":
382
+ # User confirmed - apply the change
383
+ from .config_commands import apply_config_change # noqa: PLC0415
384
+
385
+ response_text = await apply_config_change(
386
+ pending_change.config_path,
387
+ pending_change.new_value,
388
+ )
389
+
390
+ logger.info(
391
+ "Config change confirmed",
392
+ path=pending_change.config_path,
393
+ requester=event.sender,
394
+ )
395
+ else:
396
+ # User cancelled
397
+ response_text = "❌ Configuration change cancelled."
398
+ logger.info(
399
+ "Config change cancelled",
400
+ path=pending_change.config_path,
401
+ requester=event.sender,
402
+ )
403
+
404
+ # Send the response
405
+ await bot._send_response(
406
+ room.room_id,
407
+ event.reacts_to, # Reply to the confirmation message
408
+ response_text,
409
+ pending_change.thread_id,
410
+ skip_mentions=True,
411
+ )
mindroom/constants.py ADDED
@@ -0,0 +1,52 @@
1
+ """Shared constants for the mindroom package.
2
+
3
+ This module contains constants that are used across multiple modules
4
+ to avoid circular imports. It does not import anything from the internal
5
+ codebase.
6
+ """
7
+
8
+ import os
9
+ from pathlib import Path
10
+
11
+ from dotenv import load_dotenv
12
+
13
+ load_dotenv()
14
+
15
+ # Agent names
16
+ ROUTER_AGENT_NAME = "router"
17
+
18
+ # Default path to agents configuration file. Allow overriding via environment
19
+ # variable so deployments can place the writable configuration file on a
20
+ # persistent volume instead of the package directory (which may be read-only).
21
+ _CONFIG_PATH_ENV = os.getenv("MINDROOM_CONFIG_PATH") or os.getenv("CONFIG_PATH")
22
+ DEFAULT_AGENTS_CONFIG = (
23
+ Path(_CONFIG_PATH_ENV).expanduser() if _CONFIG_PATH_ENV else Path(__file__).parent.parent.parent / "config.yaml"
24
+ )
25
+
26
+ # Optional template path used to seed the writable config file if it does not
27
+ # exist yet. Defaults to the same location as DEFAULT_AGENTS_CONFIG so the
28
+ # behaviour is unchanged when no overrides are provided.
29
+ _CONFIG_TEMPLATE_ENV = os.getenv("MINDROOM_CONFIG_TEMPLATE") or os.getenv("CONFIG_TEMPLATE_PATH")
30
+ DEFAULT_CONFIG_TEMPLATE = Path(_CONFIG_TEMPLATE_ENV).expanduser() if _CONFIG_TEMPLATE_ENV else DEFAULT_AGENTS_CONFIG
31
+
32
+ STORAGE_PATH = os.getenv("STORAGE_PATH", "mindroom_data")
33
+ STORAGE_PATH_OBJ = Path(STORAGE_PATH)
34
+
35
+ # Specific files and directories
36
+ MATRIX_STATE_FILE = STORAGE_PATH_OBJ / "matrix_state.yaml"
37
+ SESSIONS_DIR = STORAGE_PATH_OBJ / "sessions"
38
+ TRACKING_DIR = STORAGE_PATH_OBJ / "tracking"
39
+ MEMORY_DIR = STORAGE_PATH_OBJ / "memory"
40
+ CREDENTIALS_DIR = STORAGE_PATH_OBJ / "credentials"
41
+ ENCRYPTION_KEYS_DIR = STORAGE_PATH_OBJ / "encryption_keys"
42
+
43
+ # Other constants
44
+ VOICE_PREFIX = "🎤 "
45
+ ENABLE_STREAMING = os.getenv("MINDROOM_ENABLE_STREAMING", "true").lower() != "false"
46
+ ENABLE_AI_CACHE = os.getenv("ENABLE_AI_CACHE", "true").lower() != "false"
47
+
48
+ # Matrix
49
+ MATRIX_HOMESERVER = os.getenv("MATRIX_HOMESERVER", "http://localhost:8008")
50
+ # (for federation setups where hostname != server_name)
51
+ MATRIX_SERVER_NAME = os.getenv("MATRIX_SERVER_NAME", None)
52
+ MATRIX_SSL_VERIFY = os.getenv("MATRIX_SSL_VERIFY", "true").lower() != "false"
@@ -0,0 +1,146 @@
1
+ """Unified credentials management for MindRoom.
2
+
3
+ This module provides centralized credential storage and retrieval for all integrations,
4
+ used by both agents and the widget interface.
5
+ """
6
+
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .constants import CREDENTIALS_DIR
12
+
13
+
14
+ class CredentialsManager:
15
+ """Centralized credentials storage and retrieval for MindRoom."""
16
+
17
+ def __init__(self, base_path: Path | None = None) -> None:
18
+ """Initialize the credentials manager.
19
+
20
+ Args:
21
+ base_path: Base directory for storing credentials.
22
+ Defaults to STORAGE_PATH/credentials (usually mindroom_data/credentials)
23
+
24
+ """
25
+ if base_path is None:
26
+ self.base_path = CREDENTIALS_DIR
27
+ else:
28
+ self.base_path = Path(base_path)
29
+
30
+ # Ensure the directory exists
31
+ self.base_path.mkdir(parents=True, exist_ok=True)
32
+
33
+ def get_credentials_path(self, service: str) -> Path:
34
+ """Get the path for a service's credentials file.
35
+
36
+ Args:
37
+ service: Name of the service (e.g., 'google', 'homeassistant')
38
+
39
+ Returns:
40
+ Path to the credentials file
41
+
42
+ """
43
+ return self.base_path / f"{service}_credentials.json"
44
+
45
+ def load_credentials(self, service: str) -> dict[str, Any] | None:
46
+ """Load credentials for a service.
47
+
48
+ Args:
49
+ service: Name of the service
50
+
51
+ Returns:
52
+ Credentials dictionary or None if not found
53
+
54
+ """
55
+ credentials_path = self.get_credentials_path(service)
56
+ if credentials_path.exists():
57
+ try:
58
+ with credentials_path.open() as f:
59
+ data: dict[str, Any] = json.load(f)
60
+ return data
61
+ except Exception:
62
+ return None
63
+ return None
64
+
65
+ def save_credentials(self, service: str, credentials: dict[str, Any]) -> None:
66
+ """Save credentials for a service.
67
+
68
+ Args:
69
+ service: Name of the service
70
+ credentials: Credentials dictionary to save
71
+
72
+ """
73
+ credentials_path = self.get_credentials_path(service)
74
+ with credentials_path.open("w") as f:
75
+ json.dump(credentials, f, indent=2)
76
+
77
+ def delete_credentials(self, service: str) -> None:
78
+ """Delete credentials for a service.
79
+
80
+ Args:
81
+ service: Name of the service
82
+
83
+ """
84
+ credentials_path = self.get_credentials_path(service)
85
+ if credentials_path.exists():
86
+ credentials_path.unlink()
87
+
88
+ def list_services(self) -> list[str]:
89
+ """List all services with stored credentials.
90
+
91
+ Returns:
92
+ List of service names
93
+
94
+ """
95
+ services = []
96
+ if self.base_path.exists():
97
+ for path in self.base_path.glob("*_credentials.json"):
98
+ service = path.stem.replace("_credentials", "")
99
+ services.append(service)
100
+ return sorted(services)
101
+
102
+ def get_api_key(self, service: str, key_name: str = "api_key") -> str | None:
103
+ """Get an API key for a service.
104
+
105
+ Args:
106
+ service: Name of the service (e.g., 'openai', 'anthropic')
107
+ key_name: Name of the key field (default: 'api_key')
108
+
109
+ Returns:
110
+ API key string or None if not found
111
+
112
+ """
113
+ credentials = self.load_credentials(service)
114
+ if credentials:
115
+ return credentials.get(key_name)
116
+ return None
117
+
118
+ def set_api_key(self, service: str, api_key: str, key_name: str = "api_key") -> None:
119
+ """Set an API key for a service.
120
+
121
+ Args:
122
+ service: Name of the service
123
+ api_key: The API key to store
124
+ key_name: Name of the key field (default: 'api_key')
125
+
126
+ """
127
+ credentials = self.load_credentials(service) or {}
128
+ credentials[key_name] = api_key
129
+ self.save_credentials(service, credentials)
130
+
131
+
132
+ # Global instance for convenience (lazy initialization)
133
+ _credentials_manager: CredentialsManager | None = None
134
+
135
+
136
+ def get_credentials_manager() -> CredentialsManager:
137
+ """Get the global credentials manager instance.
138
+
139
+ Returns:
140
+ The global CredentialsManager instance
141
+
142
+ """
143
+ global _credentials_manager
144
+ if _credentials_manager is None:
145
+ _credentials_manager = CredentialsManager()
146
+ return _credentials_manager