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,220 @@
1
+ """Tool metadata and enhanced registration system."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from typing import TYPE_CHECKING, Any, Literal
8
+
9
+ from loguru import logger
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Callable
13
+
14
+ from agno.tools import Toolkit
15
+
16
+ from mindroom.credentials import get_credentials_manager
17
+
18
+ # Registry mapping tool names to their factory functions
19
+ TOOL_REGISTRY: dict[str, Callable[[], type[Toolkit]]] = {}
20
+
21
+
22
+ def register_tool(name: str) -> Callable[[Callable[[], type[Toolkit]]], Callable[[], type[Toolkit]]]:
23
+ """Decorator to register a tool factory function.
24
+
25
+ Args:
26
+ name: The name to register the tool under
27
+
28
+ Returns:
29
+ Decorator function
30
+
31
+ """
32
+
33
+ def decorator(func: Callable[[], type[Toolkit]]) -> Callable[[], type[Toolkit]]:
34
+ TOOL_REGISTRY[name] = func
35
+ return func
36
+
37
+ return decorator
38
+
39
+
40
+ def get_tool_by_name(tool_name: str) -> Toolkit:
41
+ """Get a tool instance by its registered name."""
42
+ if tool_name not in TOOL_REGISTRY:
43
+ available = ", ".join(sorted(TOOL_REGISTRY.keys()))
44
+ msg = f"Unknown tool: {tool_name}. Available tools: {available}"
45
+ raise ValueError(msg)
46
+
47
+ try:
48
+ tool_factory = TOOL_REGISTRY[tool_name]
49
+ tool_class = tool_factory()
50
+
51
+ creds_manager = get_credentials_manager()
52
+ credentials = creds_manager.load_credentials(tool_name) or {}
53
+ metadata = TOOL_METADATA[tool_name]
54
+
55
+ init_kwargs = {}
56
+ if metadata.config_fields:
57
+ for field in metadata.config_fields:
58
+ if field.name in credentials:
59
+ init_kwargs[field.name] = credentials[field.name]
60
+
61
+ return tool_class(**init_kwargs)
62
+
63
+ except ImportError as e:
64
+ logger.warning(f"Could not import tool '{tool_name}': {e}")
65
+ logger.warning(f"Make sure the required dependencies are installed for {tool_name}")
66
+ raise
67
+
68
+
69
+ class ToolCategory(str, Enum):
70
+ """Tool categories for organization."""
71
+
72
+ EMAIL = "email"
73
+ SHOPPING = "shopping"
74
+ ENTERTAINMENT = "entertainment"
75
+ SOCIAL = "social"
76
+ DEVELOPMENT = "development"
77
+ RESEARCH = "research"
78
+ INFORMATION = "information"
79
+ PRODUCTIVITY = "productivity"
80
+ COMMUNICATION = "communication"
81
+ INTEGRATIONS = "integrations"
82
+ SMART_HOME = "smart_home"
83
+
84
+
85
+ class ToolStatus(str, Enum):
86
+ """Tool availability status."""
87
+
88
+ AVAILABLE = "available"
89
+ COMING_SOON = "coming_soon"
90
+ REQUIRES_CONFIG = "requires_config"
91
+
92
+
93
+ class SetupType(str, Enum):
94
+ """Tool setup type."""
95
+
96
+ NONE = "none" # No setup required
97
+ API_KEY = "api_key" # Requires API key
98
+ OAUTH = "oauth" # OAuth flow
99
+ SPECIAL = "special" # Special setup (e.g., for Google)
100
+ COMING_SOON = "coming_soon" # Not yet available
101
+
102
+
103
+ @dataclass
104
+ class ConfigField:
105
+ """Definition of a configuration field."""
106
+
107
+ name: str # Environment variable name (e.g., "SMTP_HOST")
108
+ label: str # Display label (e.g., "SMTP Host")
109
+ type: Literal["boolean", "number", "password", "text", "url", "select"] = "text"
110
+ required: bool = True
111
+ default: Any = None
112
+ placeholder: str | None = None
113
+ description: str | None = None
114
+ options: list[dict[str, str]] | None = None # For select type
115
+ validation: dict[str, Any] | None = None # min, max, pattern, etc.
116
+
117
+
118
+ @dataclass
119
+ class ToolMetadata:
120
+ """Complete metadata for a tool."""
121
+
122
+ name: str # Internal tool name (e.g., "gmail")
123
+ display_name: str # Display name (e.g., "Gmail")
124
+ description: str # Description for UI
125
+ category: ToolCategory
126
+ status: ToolStatus = ToolStatus.AVAILABLE
127
+ setup_type: SetupType = SetupType.NONE
128
+ icon: str | None = None # Icon identifier for frontend
129
+ icon_color: str | None = None # Tailwind color class like "text-blue-500"
130
+ config_fields: list[ConfigField] | None = None # Detailed field definitions
131
+ dependencies: list[str] | None = None # Required pip packages
132
+ auth_provider: str | None = None # Name of integration that provides auth (e.g., "google")
133
+ docs_url: str | None = None # Documentation URL
134
+ helper_text: str | None = None # Additional help text for setup
135
+ factory: Callable | None = None # Factory function to create tool instance
136
+
137
+
138
+ # Global registry for tool metadata
139
+ TOOL_METADATA: dict[str, ToolMetadata] = {}
140
+
141
+
142
+ def register_tool_with_metadata(
143
+ *,
144
+ name: str,
145
+ display_name: str,
146
+ description: str,
147
+ category: ToolCategory,
148
+ status: ToolStatus = ToolStatus.AVAILABLE,
149
+ setup_type: SetupType = SetupType.NONE,
150
+ icon: str | None = None,
151
+ icon_color: str | None = None,
152
+ config_fields: list[ConfigField] | None = None,
153
+ dependencies: list[str] | None = None,
154
+ auth_provider: str | None = None,
155
+ docs_url: str | None = None,
156
+ helper_text: str | None = None,
157
+ ) -> Callable[[Callable[[], type]], Callable[[], type]]:
158
+ """Decorator to register a tool with metadata.
159
+
160
+ This decorator stores comprehensive metadata about tools that can be used
161
+ by the frontend and other components.
162
+
163
+ Args:
164
+ name: Tool identifier used in registry
165
+ display_name: Human-readable name for UI
166
+ description: Brief description of what the tool does
167
+ category: Tool category for organization
168
+ status: Availability status of the tool
169
+ setup_type: Type of setup required
170
+ icon: Icon identifier for frontend
171
+ icon_color: CSS color class for the icon
172
+ config_fields: List of configuration fields
173
+ dependencies: Required Python packages
174
+ auth_provider: Name of integration that provides authentication
175
+ docs_url: Link to documentation
176
+ helper_text: Additional setup instructions
177
+
178
+ Returns:
179
+ Decorator function
180
+
181
+ """
182
+
183
+ def decorator(func: Callable) -> Callable:
184
+ # Create metadata object
185
+ metadata = ToolMetadata(
186
+ name=name,
187
+ display_name=display_name,
188
+ description=description,
189
+ category=category,
190
+ status=status,
191
+ setup_type=setup_type,
192
+ icon=icon,
193
+ icon_color=icon_color,
194
+ config_fields=config_fields,
195
+ dependencies=dependencies,
196
+ auth_provider=auth_provider,
197
+ docs_url=docs_url,
198
+ helper_text=helper_text,
199
+ factory=func,
200
+ )
201
+
202
+ # Store in metadata registry
203
+ TOOL_METADATA[name] = metadata
204
+
205
+ # Also register in TOOL_REGISTRY for actual tool loading
206
+ TOOL_REGISTRY[name] = func
207
+
208
+ return func
209
+
210
+ return decorator
211
+
212
+
213
+ def get_tool_metadata(name: str) -> ToolMetadata | None:
214
+ """Get metadata for a tool by name."""
215
+ return TOOL_METADATA.get(name)
216
+
217
+
218
+ def get_all_tool_metadata() -> dict[str, ToolMetadata]:
219
+ """Get all tool metadata."""
220
+ return TOOL_METADATA.copy()
@@ -0,0 +1,153 @@
1
+ """Generate contextual topics for Matrix rooms using AI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import nio
8
+ from agno.agent import Agent
9
+ from pydantic import BaseModel, Field
10
+
11
+ from mindroom.ai import _cached_agent_run, get_model_instance
12
+ from mindroom.constants import STORAGE_PATH_OBJ
13
+ from mindroom.logging_config import get_logger
14
+
15
+ if TYPE_CHECKING:
16
+ from mindroom.config import Config
17
+
18
+ logger = get_logger(__name__)
19
+
20
+
21
+ class RoomTopic(BaseModel):
22
+ """Structured room topic response."""
23
+
24
+ topic: str = Field(description="The room topic - concise, informative, with emoji")
25
+
26
+
27
+ async def generate_room_topic_ai(room_key: str, room_name: str, config: Config) -> str | None:
28
+ """Generate a contextual topic for a room using AI based on its purpose and configured agents.
29
+
30
+ Args:
31
+ room_key: The room key/alias (e.g., 'dev', 'analysis', 'lobby')
32
+ room_name: Display name for the room
33
+ config: Configuration with agent settings
34
+
35
+ Returns:
36
+ A contextual topic string for the room
37
+
38
+ """
39
+ # Get agents configured for this room
40
+ agents_in_room = []
41
+ for agent_name, agent_config in config.agents.items():
42
+ if room_key in agent_config.rooms:
43
+ display_name = agent_config.display_name or agent_name
44
+ agents_in_room.append(display_name)
45
+
46
+ # Build agent list for the prompt
47
+ agent_list = ", ".join(agents_in_room)
48
+
49
+ prompt = f"""Generate a concise, informative room topic for a MindRoom Matrix room.
50
+
51
+ Context about MindRoom:
52
+ MindRoom is a platform that frees AI agents from being trapped in single apps. Key features:
53
+ - AI agents with persistent memory that work across all platforms (Slack, Discord, Telegram, WhatsApp)
54
+ - Agents collaborate naturally in threads and remember everything across sessions
55
+ - Built on Matrix protocol for secure, federated communication
56
+ - 80+ integrations with tools like Gmail, GitHub, Spotify, Home Assistant
57
+ - Self-hosted or cloud options with military-grade encryption
58
+
59
+ Room details:
60
+ - Room key/alias: {room_key}
61
+ - Room name: {room_name}
62
+ - Configured agents: {agent_list if agent_list else "No specific agents configured yet"}
63
+
64
+ Create a topic that:
65
+ 1. Describes the room's purpose based on its name
66
+ 2. Mentions the AI agents or capabilities available
67
+ 3. Highlights MindRoom's persistent memory or cross-platform nature when relevant
68
+ 4. Is welcoming and informative
69
+ 5. Uses 1-2 relevant emojis
70
+ 6. Is under 100 characters
71
+ 7. Follows this format: [emoji] [Description] • [Capabilities/Purpose]
72
+
73
+ Examples:
74
+ - 💻 Development Hub • AI agents that remember your code patterns across sessions
75
+ - 📊 Analysis Center • Persistent insights with cross-platform data access
76
+ - 🏠 Main Lobby • Your AI team headquarters with continuous memory
77
+ - 💰 Finance Room • AI agents tracking markets 24/7 with full context
78
+ - 🔬 Research Lab • Collaborative AI exploration with shared knowledge
79
+
80
+ Generate the topic:"""
81
+
82
+ model = get_model_instance(config, "default")
83
+
84
+ agent = Agent(
85
+ name="TopicGenerator",
86
+ role="Generate contextual room topics",
87
+ model=model,
88
+ response_model=RoomTopic,
89
+ )
90
+
91
+ session_id = f"topic_{room_key}"
92
+ try:
93
+ response = await _cached_agent_run(
94
+ agent=agent,
95
+ full_prompt=prompt,
96
+ session_id=session_id,
97
+ agent_name="TopicGenerator",
98
+ storage_path=STORAGE_PATH_OBJ,
99
+ )
100
+ except Exception:
101
+ logger.exception(f"Error generating topic for room {room_key}")
102
+ return None
103
+ content = response.content
104
+ assert isinstance(content, RoomTopic) # Type narrowing for mypy
105
+ return content.topic
106
+
107
+
108
+ async def ensure_room_has_topic(
109
+ client: nio.AsyncClient,
110
+ room_id: str,
111
+ room_key: str,
112
+ room_name: str,
113
+ config: Config,
114
+ ) -> bool:
115
+ """Ensure a room has a topic set, generating one if needed.
116
+
117
+ Args:
118
+ client: Matrix client
119
+ room_id: The room ID
120
+ room_key: The room key/alias
121
+ room_name: Display name for the room
122
+ config: Configuration with agent settings
123
+
124
+ Returns:
125
+ True if topic was set or already exists, False on error
126
+
127
+ """
128
+ # Check if room already has a topic
129
+ response = await client.room_get_state_event(room_id, "m.room.topic")
130
+ if isinstance(response, nio.RoomGetStateEventResponse) and response.content.get("topic"):
131
+ logger.debug(f"Room {room_key} already has topic: {response.content['topic']}")
132
+ return True
133
+
134
+ # Generate and set topic
135
+ logger.info(f"Generating AI topic for existing room {room_key}")
136
+ topic = await generate_room_topic_ai(room_key, room_name, config)
137
+ if topic is None:
138
+ logger.warning(f"Failed to generate topic for room {room_key}")
139
+ return False
140
+
141
+ # Set the topic
142
+ response = await client.room_put_state(
143
+ room_id=room_id,
144
+ event_type="m.room.topic",
145
+ content={"topic": topic},
146
+ )
147
+
148
+ if isinstance(response, nio.RoomPutStateResponse):
149
+ logger.info(f"Set topic for room {room_key}: {topic}")
150
+ return True
151
+
152
+ logger.warning(f"Failed to set topic for room {room_key}: {response}")
153
+ return False
@@ -0,0 +1,266 @@
1
+ """Voice message handler with speech-to-text and intelligent command recognition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import ssl
7
+ import tempfile
8
+ import uuid
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ import aiofiles
13
+ import aiohttp
14
+ import nio
15
+ from agno.agent import Agent
16
+ from nio import crypto
17
+
18
+ from .ai import get_model_instance
19
+ from .commands import get_command_list
20
+ from .constants import VOICE_PREFIX
21
+ from .logging_config import get_logger
22
+
23
+ if TYPE_CHECKING:
24
+ from .config import Config
25
+
26
+ logger = get_logger(__name__)
27
+
28
+
29
+ async def handle_voice_message(
30
+ client: nio.AsyncClient,
31
+ room: nio.MatrixRoom, # noqa: ARG001
32
+ event: nio.RoomMessageAudio | nio.RoomEncryptedAudio,
33
+ config: Config,
34
+ ) -> str | None:
35
+ """Handle a voice message event.
36
+
37
+ Args:
38
+ client: Matrix client
39
+ room: Matrix room
40
+ event: Voice message event
41
+ config: Application configuration
42
+
43
+ Returns:
44
+ The transcribed and formatted message, or None if transcription failed
45
+
46
+ """
47
+ if not config.voice.enabled:
48
+ return None
49
+
50
+ try:
51
+ # Download the audio file
52
+ audio_data = await _download_audio(client, event)
53
+ if not audio_data:
54
+ logger.error("Failed to download audio file")
55
+ return None
56
+
57
+ # Transcribe the audio
58
+ transcription = await _transcribe_audio(audio_data, config)
59
+ if not transcription:
60
+ logger.warning("Failed to transcribe audio or empty transcription")
61
+ return None
62
+
63
+ logger.info(f"Raw transcription: {transcription}")
64
+
65
+ # Process transcription with AI for command/agent recognition
66
+ formatted_message = await _process_transcription(transcription, config)
67
+
68
+ logger.info(f"Formatted message: {formatted_message}")
69
+
70
+ if formatted_message:
71
+ # Add a note that this was transcribed from voice
72
+ return f"{VOICE_PREFIX}{formatted_message}"
73
+
74
+ except Exception:
75
+ logger.exception("Error handling voice message")
76
+ return None
77
+ return None
78
+
79
+
80
+ async def _download_audio(
81
+ client: nio.AsyncClient,
82
+ event: nio.RoomMessageAudio | nio.RoomEncryptedAudio,
83
+ ) -> bytes | None:
84
+ """Download and decrypt audio file from Matrix.
85
+
86
+ Args:
87
+ client: Matrix client
88
+ event: Audio event
89
+
90
+ Returns:
91
+ Audio file bytes or None if failed
92
+
93
+ """
94
+ try:
95
+ # Unencrypted audio
96
+ mxc = event.url
97
+ response = await client.download(mxc)
98
+ if isinstance(response, nio.DownloadError):
99
+ logger.error(f"Download failed: {response}")
100
+ return None
101
+ if isinstance(event, nio.RoomMessageAudio):
102
+ return response.body # type: ignore[no-any-return]
103
+
104
+ assert isinstance(event, nio.RoomEncryptedAudio)
105
+ # Decrypt the audio
106
+ return crypto.attachments.decrypt_attachment( # type: ignore[no-any-return]
107
+ response.body,
108
+ event.source["content"]["file"]["key"]["k"],
109
+ event.source["content"]["file"]["hashes"]["sha256"],
110
+ event.source["content"]["file"]["iv"],
111
+ )
112
+
113
+ except Exception:
114
+ logger.exception("Error downloading audio")
115
+ return None
116
+
117
+
118
+ async def _transcribe_audio(audio_data: bytes, config: Config) -> str | None:
119
+ """Transcribe audio using OpenAI-compatible API.
120
+
121
+ Args:
122
+ audio_data: Audio file bytes
123
+ config: Application configuration
124
+
125
+ Returns:
126
+ Transcription text or None if failed
127
+
128
+ """
129
+ try:
130
+ # Save audio to temporary file (required by most STT APIs)
131
+ with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as tmp_file:
132
+ tmp_file.write(audio_data)
133
+ tmp_path = tmp_file.name
134
+
135
+ try:
136
+ # Use OpenAI-compatible API for transcription
137
+ stt_host = config.voice.stt.host
138
+ if stt_host:
139
+ # Self-hosted solution
140
+ url = f"{stt_host}/v1/audio/transcriptions"
141
+ else:
142
+ # OpenAI or compatible cloud service
143
+ url = "https://api.openai.com/v1/audio/transcriptions"
144
+
145
+ api_key = config.voice.stt.api_key or os.getenv("OPENAI_API_KEY")
146
+ headers = {"Authorization": f"Bearer {api_key}"}
147
+
148
+ # Prepare multipart form data
149
+ async with aiofiles.open(tmp_path, "rb") as audio_file:
150
+ audio_content = await audio_file.read()
151
+
152
+ data = aiohttp.FormData()
153
+ data.add_field("file", audio_content, filename="audio.ogg", content_type="audio/ogg")
154
+ data.add_field("model", config.voice.stt.model)
155
+
156
+ # Make the API request (with SSL verification disabled if needed)
157
+ ssl_context = ssl.create_default_context()
158
+ ssl_context.check_hostname = False
159
+ ssl_context.verify_mode = ssl.CERT_NONE
160
+
161
+ connector = aiohttp.TCPConnector(ssl=ssl_context)
162
+ async with (
163
+ aiohttp.ClientSession(connector=connector) as session,
164
+ session.post(url, headers=headers, data=data) as response,
165
+ ):
166
+ if response.status != 200:
167
+ error_text = await response.text()
168
+ logger.error(f"STT API error: {response.status} - {error_text}")
169
+ return None
170
+
171
+ result = await response.json()
172
+ return result.get("text", "").strip() # type: ignore[no-any-return]
173
+
174
+ finally:
175
+ # Clean up temporary file
176
+ Path(tmp_path).unlink()
177
+
178
+ except Exception:
179
+ logger.exception("Error transcribing audio")
180
+ return None
181
+
182
+
183
+ async def _process_transcription(transcription: str, config: Config) -> str:
184
+ """Process transcription to recognize commands and agent names.
185
+
186
+ Args:
187
+ transcription: Raw transcription text
188
+ config: Application configuration
189
+
190
+ Returns:
191
+ Formatted message with proper commands and mentions
192
+
193
+ """
194
+ try:
195
+ # Get list of available agents and teams
196
+ agent_names = list(config.agents.keys())
197
+ agent_display_names = {name: cfg.display_name for name, cfg in config.agents.items()}
198
+
199
+ team_names = list(config.teams.keys()) if config.teams else []
200
+ team_display_names = {name: cfg.display_name for name, cfg in config.teams.items()} if config.teams else {}
201
+
202
+ # Build the prompt for the AI
203
+ prompt = f"""You are a voice command processor for a Matrix chat bot system.
204
+ Your task is to convert spoken transcriptions into properly formatted chat commands.
205
+
206
+ Available agents (use EXACT agent name after @):
207
+ {chr(10).join([f" - @{name} or @mindroom_{name} (spoken as: {agent_display_names[name]})" for name in agent_names])}
208
+
209
+ Available teams (use EXACT team name after @):
210
+ {chr(10).join([f" - @{name} (spoken as: {team_display_names[name]})" for name in team_names]) if team_names else " (none)"}
211
+
212
+ Examples of correct formatting:
213
+ - User says "HomeAssistant turn on the fan" → "@home turn on the fan" (NOT @homeassistant)
214
+ - User says "schedule turn off the lights in 10 minutes" → "!schedule in 10 minutes turn off the lights"
215
+ - User says "hey home assistant agent schedule to turn off the guest room lights in 10 seconds" → "!schedule in 10 seconds @home turn off the guest room lights"
216
+ - User says "cancel schedule ABC123" → "!cancel_schedule ABC123"
217
+ - User says "list my schedules" → "!list_schedules"
218
+
219
+ {get_command_list()}
220
+
221
+ CRITICAL RULES:
222
+ 1. ALWAYS use the EXACT agent name (the part before the parentheses) after @, NOT the display name
223
+ - If agent is listed as "@home (spoken as: HomeAssistant)", use "@home" NOT "@homeassistant"
224
+ 2. If the user speaks a command, format it as !command
225
+ 3. !schedule commands MUST include a time (in X minutes, at 3pm, tomorrow, etc.)
226
+ - The time should come right after !schedule
227
+ 4. When both command AND agent are mentioned, command comes FIRST
228
+ 5. Agent mentions come FIRST when just addressing them (no command):
229
+ - "research agent, find papers" → "@research find papers"
230
+ - "ask the email agent to check mail" → "@email check mail"
231
+ 6. Fix common speech recognition errors (e.g., "at research" → "@research")
232
+ 7. Be smart about intent - "ask the research agent" means "@research"
233
+ 8. Keep the natural language but add proper formatting
234
+ 9. If unclear, prefer natural language over forcing commands
235
+
236
+ Transcription: "{transcription}"
237
+
238
+ Output the formatted message only, no explanation:"""
239
+
240
+ # Get the AI model to process the transcription
241
+ model = get_model_instance(config, config.voice.intelligence.model)
242
+
243
+ # Create an agent for voice command processing
244
+ agent = Agent(
245
+ name="VoiceCommandProcessor",
246
+ role="Convert voice transcriptions to properly formatted chat commands",
247
+ model=model,
248
+ )
249
+
250
+ # Process the transcription with the agent
251
+ session_id = f"voice_process_{uuid.uuid4()}"
252
+ response = await agent.arun(prompt, session_id=session_id)
253
+
254
+ # Extract the content from the response
255
+ if response and response.content:
256
+ return response.content.strip() # type: ignore[no-any-return]
257
+
258
+ except Exception as e:
259
+ logger.exception("Error processing transcription")
260
+ # Return error message so user knows what happened
261
+ from .error_handling import get_user_friendly_error_message # noqa: PLC0415
262
+
263
+ return get_user_friendly_error_message(e, "VoiceProcessor")
264
+ else:
265
+ # Return original transcription if no valid response from model
266
+ return transcription