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.
- 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.1.dist-info/METADATA +425 -0
- mindroom-0.1.1.dist-info/RECORD +152 -0
- {mindroom-0.0.0.dist-info → mindroom-0.1.1.dist-info}/WHEEL +1 -2
- mindroom-0.1.1.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,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"
|
mindroom/credentials.py
ADDED
|
@@ -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
|