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,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
@@ -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)
@@ -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