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
mindroom/matrix/rooms.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Matrix room management functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import nio
|
|
9
|
+
|
|
10
|
+
from mindroom.logging_config import get_logger
|
|
11
|
+
from mindroom.topic_generator import ensure_room_has_topic, generate_room_topic_ai
|
|
12
|
+
|
|
13
|
+
from .client import check_and_set_avatar, create_room, join_room, matrix_client
|
|
14
|
+
from .identity import MatrixID, extract_server_name_from_homeserver
|
|
15
|
+
from .state import MatrixRoom, MatrixState
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from mindroom.config import Config
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def room_key_to_name(room_key: str) -> str:
|
|
24
|
+
"""Convert a room key to a human-readable room name.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
room_key: The room key (e.g., 'dev', 'analysis_room')
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Human-readable room name (e.g., 'Dev', 'Analysis Room')
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
return room_key.replace("_", " ").title()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_rooms() -> dict[str, MatrixRoom]:
|
|
37
|
+
"""Load room state from YAML file."""
|
|
38
|
+
state = MatrixState.load()
|
|
39
|
+
return state.rooms
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_room_aliases() -> dict[str, str]:
|
|
43
|
+
"""Get mapping of room aliases to room IDs."""
|
|
44
|
+
state = MatrixState.load()
|
|
45
|
+
return state.get_room_aliases()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_room_id(room_key: str) -> str | None:
|
|
49
|
+
"""Get room ID for a given room key/alias."""
|
|
50
|
+
state = MatrixState.load()
|
|
51
|
+
room = state.get_room(room_key)
|
|
52
|
+
return room.room_id if room else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def add_room(room_key: str, room_id: str, alias: str, name: str) -> None:
|
|
56
|
+
"""Add a new room to the state."""
|
|
57
|
+
state = MatrixState.load()
|
|
58
|
+
state.add_room(room_key, room_id, alias, name)
|
|
59
|
+
state.save()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def remove_room(room_key: str) -> bool:
|
|
63
|
+
"""Remove a room from the state."""
|
|
64
|
+
state = MatrixState.load()
|
|
65
|
+
if room_key in state.rooms:
|
|
66
|
+
del state.rooms[room_key]
|
|
67
|
+
state.save()
|
|
68
|
+
return True
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def resolve_room_aliases(room_list: list[str]) -> list[str]:
|
|
73
|
+
"""Resolve room aliases to room IDs.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
room_list: List of room aliases or IDs
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
List of room IDs (aliases resolved to IDs, IDs passed through)
|
|
80
|
+
|
|
81
|
+
"""
|
|
82
|
+
room_aliases = get_room_aliases()
|
|
83
|
+
return [room_aliases.get(room, room) for room in room_list]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_room_alias_from_id(room_id: str) -> str | None:
|
|
87
|
+
"""Get room alias from room ID (reverse lookup).
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
room_id: Matrix room ID
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Room alias if found, None otherwise
|
|
94
|
+
|
|
95
|
+
"""
|
|
96
|
+
room_aliases = get_room_aliases()
|
|
97
|
+
for alias, rid in room_aliases.items():
|
|
98
|
+
if rid == room_id:
|
|
99
|
+
return alias
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def ensure_room_exists( # noqa: C901
|
|
104
|
+
client: nio.AsyncClient,
|
|
105
|
+
room_key: str,
|
|
106
|
+
config: Config,
|
|
107
|
+
room_name: str | None = None,
|
|
108
|
+
power_users: list[str] | None = None,
|
|
109
|
+
) -> str | None:
|
|
110
|
+
"""Ensure a room exists, creating it if necessary.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
client: Matrix client to use for room creation
|
|
114
|
+
room_key: The room key/alias (without domain)
|
|
115
|
+
config: Configuration with agent settings for topic generation
|
|
116
|
+
room_name: Display name for the room (defaults to room_key with underscores replaced)
|
|
117
|
+
power_users: List of user IDs to grant power levels to
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Room ID if room exists or was created, None on failure
|
|
121
|
+
|
|
122
|
+
"""
|
|
123
|
+
existing_rooms = load_rooms()
|
|
124
|
+
|
|
125
|
+
# First, try to resolve the room alias on the server
|
|
126
|
+
# This handles cases where the room exists on server but not in our state
|
|
127
|
+
server_name = extract_server_name_from_homeserver(client.homeserver)
|
|
128
|
+
full_alias = f"#{room_key}:{server_name}"
|
|
129
|
+
|
|
130
|
+
response = await client.room_resolve_alias(full_alias)
|
|
131
|
+
if isinstance(response, nio.RoomResolveAliasResponse):
|
|
132
|
+
room_id = response.room_id
|
|
133
|
+
logger.debug(f"Room alias {full_alias} exists on server, room ID: {room_id}")
|
|
134
|
+
|
|
135
|
+
# Update our state if needed
|
|
136
|
+
if room_key not in existing_rooms or existing_rooms[room_key].room_id != room_id:
|
|
137
|
+
if room_name is None:
|
|
138
|
+
room_name = room_key_to_name(room_key)
|
|
139
|
+
add_room(room_key, room_id, full_alias, room_name)
|
|
140
|
+
logger.info(f"Updated state with existing room {room_key} (ID: {room_id})")
|
|
141
|
+
|
|
142
|
+
# Try to join the room
|
|
143
|
+
if await join_room(client, room_id):
|
|
144
|
+
# For existing rooms, ensure they have a topic set
|
|
145
|
+
if room_name is None:
|
|
146
|
+
room_name = room_key_to_name(room_key)
|
|
147
|
+
await ensure_room_has_topic(client, room_id, room_key, room_name, config)
|
|
148
|
+
return str(room_id)
|
|
149
|
+
# Room exists but we can't join - this means the room was created
|
|
150
|
+
# but this user isn't a member. Return the room ID anyway since
|
|
151
|
+
# the room does exist and invitations will be handled separately
|
|
152
|
+
logger.debug(f"Room {room_key} exists but user not a member, returning room ID for invitation handling")
|
|
153
|
+
return str(room_id)
|
|
154
|
+
|
|
155
|
+
# Room alias doesn't exist on server, so we can create it
|
|
156
|
+
if room_key in existing_rooms:
|
|
157
|
+
# Remove stale entry from state
|
|
158
|
+
logger.debug(f"Removing stale room {room_key} from state")
|
|
159
|
+
remove_room(room_key)
|
|
160
|
+
|
|
161
|
+
# Create the room
|
|
162
|
+
if room_name is None:
|
|
163
|
+
room_name = room_key_to_name(room_key)
|
|
164
|
+
|
|
165
|
+
# Generate a contextual topic for the room using AI
|
|
166
|
+
topic = await generate_room_topic_ai(room_key, room_name, config)
|
|
167
|
+
logger.info(f"Creating room {room_key} with topic: {topic}")
|
|
168
|
+
|
|
169
|
+
created_room_id = await create_room(
|
|
170
|
+
client=client,
|
|
171
|
+
name=room_name,
|
|
172
|
+
alias=room_key,
|
|
173
|
+
topic=topic,
|
|
174
|
+
power_users=power_users or [],
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if created_room_id:
|
|
178
|
+
# Save room info
|
|
179
|
+
add_room(room_key, created_room_id, full_alias, room_name)
|
|
180
|
+
logger.info(f"Created room {room_key} with ID {created_room_id}")
|
|
181
|
+
|
|
182
|
+
# Set room avatar if available (for newly created rooms)
|
|
183
|
+
# Note: Avatars can also be updated later using scripts/generate_avatars.py
|
|
184
|
+
avatar_path = Path(__file__).parent.parent.parent.parent / "avatars" / "rooms" / f"{room_key}.png"
|
|
185
|
+
if avatar_path.exists():
|
|
186
|
+
if await check_and_set_avatar(client, avatar_path, room_id=created_room_id):
|
|
187
|
+
logger.info(f"Set avatar for newly created room {room_key}")
|
|
188
|
+
else:
|
|
189
|
+
logger.warning(f"Failed to set avatar for room {room_key}")
|
|
190
|
+
|
|
191
|
+
return created_room_id
|
|
192
|
+
logger.error(f"Failed to create room {room_key}")
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def ensure_all_rooms_exist(
|
|
197
|
+
client: nio.AsyncClient,
|
|
198
|
+
config: Config,
|
|
199
|
+
) -> dict[str, str]:
|
|
200
|
+
"""Ensure all configured rooms exist and invite user account.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
client: Matrix client to use for room creation
|
|
204
|
+
config: Configuration with room settings
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Dict mapping room keys to room IDs
|
|
208
|
+
|
|
209
|
+
"""
|
|
210
|
+
from mindroom.agents import get_agent_ids_for_room # noqa: PLC0415
|
|
211
|
+
|
|
212
|
+
room_ids = {}
|
|
213
|
+
|
|
214
|
+
# Get all configured rooms
|
|
215
|
+
all_rooms = config.get_all_configured_rooms()
|
|
216
|
+
|
|
217
|
+
for room_key in all_rooms:
|
|
218
|
+
# Skip if this is a room ID (starts with !)
|
|
219
|
+
if room_key.startswith("!"):
|
|
220
|
+
# This is a room ID, not a room key/alias - skip it
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
# Get power users for this room
|
|
224
|
+
power_users = get_agent_ids_for_room(room_key, config)
|
|
225
|
+
|
|
226
|
+
# Ensure room exists
|
|
227
|
+
room_id = await ensure_room_exists(
|
|
228
|
+
client=client,
|
|
229
|
+
room_key=room_key,
|
|
230
|
+
config=config,
|
|
231
|
+
power_users=power_users,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if room_id:
|
|
235
|
+
room_ids[room_key] = room_id
|
|
236
|
+
|
|
237
|
+
return room_ids
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
async def ensure_user_in_rooms(homeserver: str, room_ids: dict[str, str]) -> None:
|
|
241
|
+
"""Ensure the user account is a member of all specified rooms.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
homeserver: Matrix homeserver URL
|
|
245
|
+
room_ids: Dict mapping room keys to room IDs
|
|
246
|
+
|
|
247
|
+
"""
|
|
248
|
+
state = MatrixState.load()
|
|
249
|
+
# User account is stored as "agent_user" (treated as a special agent)
|
|
250
|
+
user_account = state.get_account("agent_user")
|
|
251
|
+
if not user_account:
|
|
252
|
+
logger.warning("No user account found, skipping user room membership")
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
server_name = extract_server_name_from_homeserver(homeserver)
|
|
256
|
+
user_id = MatrixID.from_username(user_account.username, server_name).full_id
|
|
257
|
+
|
|
258
|
+
# Create a client for the user to join rooms
|
|
259
|
+
async with matrix_client(homeserver, user_id) as user_client:
|
|
260
|
+
# Login as the user
|
|
261
|
+
login_response = await user_client.login(password=user_account.password)
|
|
262
|
+
if not isinstance(login_response, nio.LoginResponse):
|
|
263
|
+
logger.error(f"Failed to login as user {user_id}: {login_response}")
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
logger.info(f"User {user_id} logged in to join rooms")
|
|
267
|
+
|
|
268
|
+
for room_key, room_id in room_ids.items():
|
|
269
|
+
# Try to join the room (will work if invited or room is public)
|
|
270
|
+
join_success = await join_room(user_client, room_id)
|
|
271
|
+
if join_success:
|
|
272
|
+
logger.info(f"User {user_id} joined room {room_key}")
|
|
273
|
+
else:
|
|
274
|
+
logger.warning(f"User {user_id} failed to join room {room_key} - may need invitation")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
DM_ROOM_CACHE: dict[str, bool] = {}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
async def is_dm_room(client: nio.AsyncClient, room_id: str) -> bool:
|
|
281
|
+
"""Check if a room is a Direct Message (DM) room.
|
|
282
|
+
|
|
283
|
+
DM rooms have the "is_direct" flag set to true in member state events.
|
|
284
|
+
This function checks the room state to determine if it's a DM.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
client: The Matrix client
|
|
288
|
+
room_id: The room ID to check
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
True if the room is a DM room, False otherwise
|
|
292
|
+
|
|
293
|
+
"""
|
|
294
|
+
if room_id in DM_ROOM_CACHE:
|
|
295
|
+
return DM_ROOM_CACHE[room_id]
|
|
296
|
+
# Get the room state events, specifically member events
|
|
297
|
+
response = await client.room_get_state(room_id)
|
|
298
|
+
|
|
299
|
+
if isinstance(response, nio.RoomGetStateResponse):
|
|
300
|
+
# Check member events for the is_direct flag
|
|
301
|
+
for event in response.events:
|
|
302
|
+
if event.get("type") == "m.room.member":
|
|
303
|
+
content = event.get("content", {})
|
|
304
|
+
if content.get("is_direct") is True:
|
|
305
|
+
# Cache the result for this room ID
|
|
306
|
+
DM_ROOM_CACHE[room_id] = True
|
|
307
|
+
return True
|
|
308
|
+
|
|
309
|
+
# If we can't find is_direct=true in any member event, it's not a DM
|
|
310
|
+
DM_ROOM_CACHE[room_id] = False
|
|
311
|
+
return False
|
mindroom/matrix/state.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Pydantic models for Matrix state."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from typing import Self
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from pydantic import BaseModel, Field, field_serializer
|
|
8
|
+
|
|
9
|
+
from mindroom.constants import MATRIX_STATE_FILE
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MatrixAccount(BaseModel):
|
|
13
|
+
"""Represents a Matrix account (user or agent)."""
|
|
14
|
+
|
|
15
|
+
username: str
|
|
16
|
+
password: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MatrixRoom(BaseModel):
|
|
20
|
+
"""Represents a Matrix room state."""
|
|
21
|
+
|
|
22
|
+
room_id: str
|
|
23
|
+
alias: str
|
|
24
|
+
name: str
|
|
25
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
26
|
+
|
|
27
|
+
@field_serializer("created_at")
|
|
28
|
+
def serialize_datetime(self, dt: datetime) -> str:
|
|
29
|
+
"""Serialize datetime to ISO format string."""
|
|
30
|
+
return dt.isoformat()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MatrixState(BaseModel):
|
|
34
|
+
"""Complete Matrix state including accounts and rooms."""
|
|
35
|
+
|
|
36
|
+
accounts: dict[str, MatrixAccount] = Field(default_factory=dict)
|
|
37
|
+
rooms: dict[str, MatrixRoom] = Field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def load(cls) -> Self:
|
|
41
|
+
"""Load state from file."""
|
|
42
|
+
if not MATRIX_STATE_FILE.exists():
|
|
43
|
+
return cls()
|
|
44
|
+
|
|
45
|
+
with MATRIX_STATE_FILE.open() as f:
|
|
46
|
+
data = yaml.safe_load(f) or {}
|
|
47
|
+
|
|
48
|
+
return cls.model_validate(data)
|
|
49
|
+
|
|
50
|
+
def save(self) -> None:
|
|
51
|
+
"""Save state to file."""
|
|
52
|
+
# Use Pydantic's model_dump with custom serializer for datetime
|
|
53
|
+
data = self.model_dump(mode="json")
|
|
54
|
+
|
|
55
|
+
MATRIX_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
with MATRIX_STATE_FILE.open("w") as f:
|
|
57
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
58
|
+
|
|
59
|
+
def get_account(self, key: str) -> MatrixAccount | None:
|
|
60
|
+
"""Get an account by key."""
|
|
61
|
+
return self.accounts.get(key)
|
|
62
|
+
|
|
63
|
+
def add_account(self, key: str, username: str, password: str) -> None:
|
|
64
|
+
"""Add or update an account."""
|
|
65
|
+
self.accounts[key] = MatrixAccount(username=username, password=password)
|
|
66
|
+
|
|
67
|
+
def get_room(self, key: str) -> MatrixRoom | None:
|
|
68
|
+
"""Get a room by key."""
|
|
69
|
+
return self.rooms.get(key)
|
|
70
|
+
|
|
71
|
+
def add_room(self, key: str, room_id: str, alias: str, name: str) -> None:
|
|
72
|
+
"""Add or update a room."""
|
|
73
|
+
self.rooms[key] = MatrixRoom(room_id=room_id, alias=alias, name=name, created_at=datetime.now(tz=UTC))
|
|
74
|
+
|
|
75
|
+
def get_room_aliases(self) -> dict[str, str]:
|
|
76
|
+
"""Get mapping of room aliases to room IDs."""
|
|
77
|
+
return {key: room.room_id for key, room in self.rooms.items()}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Typing indicator management for Matrix agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from contextlib import asynccontextmanager, suppress
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import nio
|
|
10
|
+
|
|
11
|
+
from mindroom.logging_config import get_logger
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import AsyncGenerator
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def set_typing(
|
|
20
|
+
client: nio.AsyncClient,
|
|
21
|
+
room_id: str,
|
|
22
|
+
typing: bool = True,
|
|
23
|
+
timeout_seconds: int = 30,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Set typing status for a user in a room.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
client: Matrix client instance
|
|
29
|
+
room_id: Room to show typing indicator in
|
|
30
|
+
typing: Whether to show or hide typing indicator
|
|
31
|
+
timeout_seconds: How long the typing indicator should last (in seconds)
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
timeout_ms = timeout_seconds * 1000
|
|
35
|
+
response = await client.room_typing(room_id, typing, timeout_ms)
|
|
36
|
+
if isinstance(response, nio.RoomTypingError):
|
|
37
|
+
logger.warning(
|
|
38
|
+
"Failed to set typing status",
|
|
39
|
+
room_id=room_id,
|
|
40
|
+
typing=typing,
|
|
41
|
+
error=response.message,
|
|
42
|
+
)
|
|
43
|
+
else:
|
|
44
|
+
logger.debug("Set typing status", room_id=room_id, typing=typing)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@asynccontextmanager
|
|
48
|
+
async def typing_indicator(
|
|
49
|
+
client: nio.AsyncClient,
|
|
50
|
+
room_id: str,
|
|
51
|
+
timeout_seconds: int = 30,
|
|
52
|
+
) -> AsyncGenerator[None, None]:
|
|
53
|
+
"""Context manager for showing typing indicator while processing.
|
|
54
|
+
|
|
55
|
+
Usage:
|
|
56
|
+
async with typing_indicator(client, room_id):
|
|
57
|
+
# Do work here - typing indicator shown
|
|
58
|
+
response = await generate_response()
|
|
59
|
+
# Typing indicator automatically stopped
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
client: Matrix client instance
|
|
63
|
+
room_id: Room to show typing indicator in
|
|
64
|
+
timeout_seconds: How long each typing notification lasts
|
|
65
|
+
|
|
66
|
+
"""
|
|
67
|
+
# Start typing
|
|
68
|
+
await set_typing(client, room_id, True, timeout_seconds)
|
|
69
|
+
|
|
70
|
+
# Create a task to periodically refresh the typing indicator
|
|
71
|
+
# Matrix typing indicators expire, so we need to refresh them
|
|
72
|
+
refresh_interval = min(timeout_seconds / 2, 15) # Refresh at half timeout or 15s
|
|
73
|
+
|
|
74
|
+
async def refresh_typing() -> None:
|
|
75
|
+
"""Refresh typing indicator periodically."""
|
|
76
|
+
while True:
|
|
77
|
+
await asyncio.sleep(refresh_interval)
|
|
78
|
+
await set_typing(client, room_id, True, timeout_seconds)
|
|
79
|
+
|
|
80
|
+
refresh_task = asyncio.create_task(refresh_typing())
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
yield
|
|
84
|
+
finally:
|
|
85
|
+
# Cancel refresh task
|
|
86
|
+
refresh_task.cancel()
|
|
87
|
+
with suppress(asyncio.CancelledError):
|
|
88
|
+
await refresh_task
|
|
89
|
+
|
|
90
|
+
# Stop typing
|
|
91
|
+
await set_typing(client, room_id, False)
|
mindroom/matrix/users.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Matrix user account management for agents."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from functools import cached_property
|
|
5
|
+
|
|
6
|
+
import nio
|
|
7
|
+
|
|
8
|
+
from mindroom.config import Config
|
|
9
|
+
from mindroom.constants import ROUTER_AGENT_NAME
|
|
10
|
+
from mindroom.logging_config import get_logger
|
|
11
|
+
|
|
12
|
+
from .client import login, register_user
|
|
13
|
+
from .identity import MatrixID, extract_server_name_from_homeserver
|
|
14
|
+
from .state import MatrixState
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def extract_domain_from_user_id(user_id: str) -> str:
|
|
20
|
+
"""Extract domain from a Matrix user ID like "@user:example.com"."""
|
|
21
|
+
if not user_id.startswith("@") or ":" not in user_id:
|
|
22
|
+
return "localhost"
|
|
23
|
+
return MatrixID.parse(user_id).domain
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class AgentMatrixUser:
|
|
28
|
+
"""Represents a Matrix user account for an agent."""
|
|
29
|
+
|
|
30
|
+
agent_name: str
|
|
31
|
+
user_id: str
|
|
32
|
+
display_name: str
|
|
33
|
+
password: str
|
|
34
|
+
access_token: str | None = None
|
|
35
|
+
|
|
36
|
+
@cached_property
|
|
37
|
+
def matrix_id(self) -> MatrixID:
|
|
38
|
+
"""MatrixID object from user_id."""
|
|
39
|
+
return MatrixID.parse(self.user_id)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_agent_credentials(agent_name: str) -> dict[str, str] | None:
|
|
43
|
+
"""Get credentials for a specific agent from matrix_state.yaml.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
agent_name: The agent name
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Dictionary with username and password, or None if not found
|
|
50
|
+
|
|
51
|
+
"""
|
|
52
|
+
state = MatrixState.load()
|
|
53
|
+
agent_key = f"agent_{agent_name}"
|
|
54
|
+
account = state.get_account(agent_key)
|
|
55
|
+
if account:
|
|
56
|
+
return {"username": account.username, "password": account.password}
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def save_agent_credentials(agent_name: str, username: str, password: str) -> None:
|
|
61
|
+
"""Save credentials for a specific agent to matrix_state.yaml.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
agent_name: The agent name
|
|
65
|
+
username: The Matrix username
|
|
66
|
+
password: The Matrix password
|
|
67
|
+
|
|
68
|
+
"""
|
|
69
|
+
state = MatrixState.load()
|
|
70
|
+
agent_key = f"agent_{agent_name}"
|
|
71
|
+
state.add_account(agent_key, username, password)
|
|
72
|
+
state.save()
|
|
73
|
+
logger.info(f"Saved credentials for agent {agent_name}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def create_agent_user(
|
|
77
|
+
homeserver: str,
|
|
78
|
+
agent_name: str,
|
|
79
|
+
agent_display_name: str,
|
|
80
|
+
) -> AgentMatrixUser:
|
|
81
|
+
"""Create or retrieve a Matrix user account for an agent.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
homeserver: The Matrix homeserver URL
|
|
85
|
+
agent_name: The internal agent name (e.g., 'calculator')
|
|
86
|
+
agent_display_name: The display name for the agent (e.g., 'CalculatorAgent')
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
AgentMatrixUser object with account details
|
|
90
|
+
|
|
91
|
+
"""
|
|
92
|
+
# Check if credentials already exist in matrix_state.yaml
|
|
93
|
+
existing_creds = get_agent_credentials(agent_name)
|
|
94
|
+
|
|
95
|
+
if existing_creds:
|
|
96
|
+
username = existing_creds["username"]
|
|
97
|
+
password = existing_creds["password"]
|
|
98
|
+
logger.info(f"Using existing credentials for agent {agent_name} from matrix_state.yaml")
|
|
99
|
+
registration_needed = False
|
|
100
|
+
else:
|
|
101
|
+
# Generate new credentials
|
|
102
|
+
username = f"mindroom_{agent_name}"
|
|
103
|
+
password = f"{agent_name}_secure_password" # _{os.urandom(8).hex()}"
|
|
104
|
+
logger.info(f"Generated new credentials for agent {agent_name}")
|
|
105
|
+
registration_needed = True
|
|
106
|
+
|
|
107
|
+
# Extract server name from homeserver URL
|
|
108
|
+
server_name = extract_server_name_from_homeserver(homeserver)
|
|
109
|
+
user_id = MatrixID.from_username(username, server_name).full_id
|
|
110
|
+
|
|
111
|
+
# Try to register/verify the user
|
|
112
|
+
try:
|
|
113
|
+
await register_user(
|
|
114
|
+
homeserver=homeserver,
|
|
115
|
+
username=username,
|
|
116
|
+
password=password,
|
|
117
|
+
display_name=agent_display_name,
|
|
118
|
+
)
|
|
119
|
+
# Only save credentials after successful registration
|
|
120
|
+
if registration_needed:
|
|
121
|
+
save_agent_credentials(agent_name, username, password)
|
|
122
|
+
logger.info(f"Saved credentials for agent {agent_name} after successful registration")
|
|
123
|
+
except ValueError as e:
|
|
124
|
+
# If user already exists, that's fine
|
|
125
|
+
error_msg = str(e) if e else ""
|
|
126
|
+
logger.debug(f"ValueError when registering {username}: {error_msg}")
|
|
127
|
+
if "already exists" not in error_msg and "RegisterErrorResponse" not in error_msg:
|
|
128
|
+
raise
|
|
129
|
+
# Save credentials if the user already exists (registration succeeded in the past)
|
|
130
|
+
if registration_needed and "already exists" in error_msg:
|
|
131
|
+
save_agent_credentials(agent_name, username, password)
|
|
132
|
+
logger.info(f"Saved credentials for agent {agent_name} (user already exists)")
|
|
133
|
+
|
|
134
|
+
return AgentMatrixUser(
|
|
135
|
+
agent_name=agent_name,
|
|
136
|
+
user_id=user_id,
|
|
137
|
+
display_name=agent_display_name,
|
|
138
|
+
password=password,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def login_agent_user(homeserver: str, agent_user: AgentMatrixUser) -> nio.AsyncClient:
|
|
143
|
+
"""Login an agent user and return the authenticated client.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
homeserver: The Matrix homeserver URL
|
|
147
|
+
agent_user: The agent user to login
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Authenticated AsyncClient instance
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
ValueError: If login fails
|
|
154
|
+
|
|
155
|
+
"""
|
|
156
|
+
client = await login(homeserver, agent_user.user_id, agent_user.password)
|
|
157
|
+
agent_user.access_token = client.access_token
|
|
158
|
+
return client
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# TODO: Check, this seems unused!
|
|
162
|
+
async def ensure_all_agent_users(homeserver: str, config: Config) -> dict[str, AgentMatrixUser]:
|
|
163
|
+
"""Ensure all configured agents and teams have Matrix user accounts.
|
|
164
|
+
|
|
165
|
+
This includes user-configured agents, teams, and the built-in router agent.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
homeserver: The Matrix homeserver URL
|
|
169
|
+
config: Application configuration
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Dictionary mapping agent/team names to AgentMatrixUser objects
|
|
173
|
+
|
|
174
|
+
"""
|
|
175
|
+
agent_users = {}
|
|
176
|
+
|
|
177
|
+
# First, create the built-in router agent
|
|
178
|
+
try:
|
|
179
|
+
router_user = await create_agent_user(
|
|
180
|
+
homeserver,
|
|
181
|
+
ROUTER_AGENT_NAME,
|
|
182
|
+
"RouterAgent",
|
|
183
|
+
)
|
|
184
|
+
agent_users[ROUTER_AGENT_NAME] = router_user
|
|
185
|
+
logger.info(f"Ensured Matrix user for built-in router agent: {router_user.user_id}")
|
|
186
|
+
except Exception:
|
|
187
|
+
logger.exception("Failed to create Matrix user for built-in router agent")
|
|
188
|
+
|
|
189
|
+
# Create user-configured agents
|
|
190
|
+
for agent_name, agent_config in config.agents.items():
|
|
191
|
+
try:
|
|
192
|
+
agent_user = await create_agent_user(
|
|
193
|
+
homeserver,
|
|
194
|
+
agent_name,
|
|
195
|
+
agent_config.display_name,
|
|
196
|
+
)
|
|
197
|
+
agent_users[agent_name] = agent_user
|
|
198
|
+
logger.info(f"Ensured Matrix user for agent: {agent_name} -> {agent_user.user_id}")
|
|
199
|
+
except Exception:
|
|
200
|
+
# Continue with other agents even if one fails
|
|
201
|
+
logger.exception("Failed to create Matrix user for agent", agent_name=agent_name)
|
|
202
|
+
|
|
203
|
+
# Create team users
|
|
204
|
+
for team_name, team_config in config.teams.items():
|
|
205
|
+
try:
|
|
206
|
+
team_user = await create_agent_user(
|
|
207
|
+
homeserver,
|
|
208
|
+
team_name,
|
|
209
|
+
team_config.display_name,
|
|
210
|
+
)
|
|
211
|
+
agent_users[team_name] = team_user
|
|
212
|
+
logger.info(f"Ensured Matrix user for team: {team_name} -> {team_user.user_id}")
|
|
213
|
+
except Exception:
|
|
214
|
+
# Continue with other teams even if one fails
|
|
215
|
+
logger.exception("Failed to create Matrix user for team", team_name=team_name)
|
|
216
|
+
|
|
217
|
+
return agent_users
|