mindroom 0.0.0__py3-none-any.whl → 0.1.0__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.0.dist-info/METADATA +425 -0
- mindroom-0.1.0.dist-info/RECORD +152 -0
- {mindroom-0.0.0.dist-info → mindroom-0.1.0.dist-info}/WHEEL +1 -2
- mindroom-0.1.0.dist-info/entry_points.txt +2 -0
- mindroom-0.0.0.dist-info/METADATA +0 -24
- mindroom-0.0.0.dist-info/RECORD +0 -4
- mindroom-0.0.0.dist-info/top_level.txt +0 -1
|
@@ -0,0 +1,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
|