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
mindroom/scheduling.py
ADDED
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
"""Scheduled task management with AI-powered workflow scheduling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
from typing import TYPE_CHECKING, Literal, NamedTuple
|
|
10
|
+
from zoneinfo import ZoneInfo
|
|
11
|
+
|
|
12
|
+
import humanize
|
|
13
|
+
import nio
|
|
14
|
+
from agno.agent import Agent
|
|
15
|
+
from cron_descriptor import get_description # type: ignore[import-untyped]
|
|
16
|
+
from croniter import croniter # type: ignore[import-untyped]
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
from .ai import get_model_instance
|
|
20
|
+
from .logging_config import get_logger
|
|
21
|
+
from .matrix.client import (
|
|
22
|
+
fetch_thread_history,
|
|
23
|
+
get_latest_thread_event_id_if_needed,
|
|
24
|
+
send_message,
|
|
25
|
+
)
|
|
26
|
+
from .matrix.identity import MatrixID
|
|
27
|
+
from .matrix.mentions import format_message_with_mentions, parse_mentions_in_text
|
|
28
|
+
from .matrix.message_builder import build_message_content
|
|
29
|
+
from .thread_utils import get_agents_in_thread, get_available_agents_in_room
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from .config import Config
|
|
33
|
+
|
|
34
|
+
logger = get_logger(__name__)
|
|
35
|
+
|
|
36
|
+
# Event type for scheduled tasks in Matrix state
|
|
37
|
+
SCHEDULED_TASK_EVENT_TYPE = "com.mindroom.scheduled.task"
|
|
38
|
+
|
|
39
|
+
# Maximum length for message preview in task listings
|
|
40
|
+
MESSAGE_PREVIEW_LENGTH = 50
|
|
41
|
+
|
|
42
|
+
# Global task storage for running asyncio tasks
|
|
43
|
+
_running_tasks: dict[str, asyncio.Task] = {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _AgentValidationResult(NamedTuple):
|
|
47
|
+
"""Result of agent mention validation."""
|
|
48
|
+
|
|
49
|
+
all_valid: bool
|
|
50
|
+
valid_agents: list[MatrixID]
|
|
51
|
+
invalid_agents: list[MatrixID]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---- Workflow scheduling primitives ----
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class CronSchedule(BaseModel):
|
|
58
|
+
"""Standard cron-like schedule definition."""
|
|
59
|
+
|
|
60
|
+
minute: str = Field(default="*", description="0-59, *, */5, or comma-separated")
|
|
61
|
+
hour: str = Field(default="*", description="0-23, *, */2, or comma-separated")
|
|
62
|
+
day: str = Field(default="*", description="1-31, *, or comma-separated")
|
|
63
|
+
month: str = Field(default="*", description="1-12, *, or comma-separated")
|
|
64
|
+
weekday: str = Field(default="*", description="0-6 (0=Sunday), *, or comma-separated")
|
|
65
|
+
|
|
66
|
+
def to_cron_string(self) -> str:
|
|
67
|
+
"""Convert to standard cron format."""
|
|
68
|
+
return f"{self.minute} {self.hour} {self.day} {self.month} {self.weekday}"
|
|
69
|
+
|
|
70
|
+
def to_natural_language(self) -> str:
|
|
71
|
+
"""Convert cron schedule to natural language description."""
|
|
72
|
+
try:
|
|
73
|
+
cron_str = self.to_cron_string()
|
|
74
|
+
return str(get_description(cron_str))
|
|
75
|
+
except Exception:
|
|
76
|
+
return f"Cron: {self.to_cron_string()}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ScheduledWorkflow(BaseModel):
|
|
80
|
+
"""Structured representation of a scheduled task or workflow."""
|
|
81
|
+
|
|
82
|
+
schedule_type: Literal["once", "cron"]
|
|
83
|
+
execute_at: datetime | None = None
|
|
84
|
+
cron_schedule: CronSchedule | None = None
|
|
85
|
+
message: str
|
|
86
|
+
description: str
|
|
87
|
+
created_by: str | None = None
|
|
88
|
+
thread_id: str | None = None
|
|
89
|
+
room_id: str | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class WorkflowParseError(BaseModel):
|
|
93
|
+
"""Error response when workflow parsing fails."""
|
|
94
|
+
|
|
95
|
+
error: str
|
|
96
|
+
suggestion: str | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def parse_workflow_schedule(
|
|
100
|
+
request: str,
|
|
101
|
+
config: Config,
|
|
102
|
+
available_agents: list[MatrixID],
|
|
103
|
+
current_time: datetime | None = None,
|
|
104
|
+
) -> ScheduledWorkflow | WorkflowParseError:
|
|
105
|
+
"""Parse natural language into structured workflow using AI."""
|
|
106
|
+
if current_time is None:
|
|
107
|
+
current_time = datetime.now(UTC)
|
|
108
|
+
|
|
109
|
+
assert available_agents, "No agents available for scheduling"
|
|
110
|
+
agent_list = ", ".join(f"@{name}" for name in available_agents)
|
|
111
|
+
|
|
112
|
+
prompt = f"""Parse this scheduling request into a structured workflow.
|
|
113
|
+
|
|
114
|
+
Current time (UTC): {current_time.isoformat()}Z
|
|
115
|
+
Request: "{request}"
|
|
116
|
+
|
|
117
|
+
Your task is to:
|
|
118
|
+
1. Determine if this is a one-time task or recurring (cron)
|
|
119
|
+
2. Extract the schedule/timing
|
|
120
|
+
3. Create a message that mentions the appropriate agents
|
|
121
|
+
|
|
122
|
+
Available agents: {agent_list}
|
|
123
|
+
|
|
124
|
+
IMPORTANT: Event-based and conditional requests:
|
|
125
|
+
When users say "if", "when", "whenever", "once X happens" or describe events/conditions:
|
|
126
|
+
1. Convert to an appropriate recurring (cron) schedule for polling
|
|
127
|
+
2. Include BOTH the condition check AND the action in the message
|
|
128
|
+
3. Choose polling frequency based on urgency and type
|
|
129
|
+
|
|
130
|
+
Important rules:
|
|
131
|
+
- For conditional/event-based requests, ALWAYS include the check condition in the message
|
|
132
|
+
- Mention relevant agents with @ only when needed
|
|
133
|
+
- Convert time expressions to UTC for the schedule, but DO NOT include them in the message
|
|
134
|
+
- Remove time phrases like "in 15 seconds" from the message itself
|
|
135
|
+
- If schedule_type is "once", you MUST provide execute_at
|
|
136
|
+
- If schedule_type is "cron", you MUST provide cron_schedule
|
|
137
|
+
|
|
138
|
+
Examples of event/condition phrasing to include in the message (do not include times in these examples):
|
|
139
|
+
- @email_assistant Check for emails containing 'urgent'. If found, @phone_agent notify the user.
|
|
140
|
+
- @crypto_agent Check Bitcoin price. If below $40,000, @notification_agent alert the user.
|
|
141
|
+
- @monitoring_agent Check server CPU usage. If above 80%, @ops_agent scale up the servers.
|
|
142
|
+
- @reddit_agent Check for new mentions of our product. If found, @analyst analyze the sentiment and key points.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
model = get_model_instance(config, "default")
|
|
146
|
+
|
|
147
|
+
agent = Agent(
|
|
148
|
+
name="WorkflowParser",
|
|
149
|
+
role="Parse scheduling requests into structured workflows",
|
|
150
|
+
model=model,
|
|
151
|
+
response_model=ScheduledWorkflow,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
response = await agent.arun(prompt, session_id=f"workflow_parse_{uuid.uuid4()}")
|
|
156
|
+
result = response.content
|
|
157
|
+
|
|
158
|
+
if isinstance(result, ScheduledWorkflow):
|
|
159
|
+
if result.schedule_type == "once" and not result.execute_at:
|
|
160
|
+
# Match previous behavior: default to 30 minutes from now
|
|
161
|
+
result.execute_at = current_time + timedelta(minutes=30)
|
|
162
|
+
elif result.schedule_type == "cron" and not result.cron_schedule:
|
|
163
|
+
result.cron_schedule = CronSchedule(minute="0", hour="9", day="*", month="*", weekday="*")
|
|
164
|
+
|
|
165
|
+
logger.info("Successfully parsed workflow schedule", request=request, schedule_type=result.schedule_type)
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
logger.error("Unexpected response type from AI", response_type=type(result).__name__)
|
|
169
|
+
return WorkflowParseError(
|
|
170
|
+
error="Failed to parse the schedule request",
|
|
171
|
+
suggestion="Try being more specific about the timing and what you want to happen",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
except Exception as e:
|
|
175
|
+
logger.exception("Error parsing workflow schedule", error=str(e), request=request)
|
|
176
|
+
return WorkflowParseError(
|
|
177
|
+
error=f"Error parsing schedule: {e!s}",
|
|
178
|
+
suggestion="Try a simpler format like 'Daily at 9am, check my email'",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def execute_scheduled_workflow(
|
|
183
|
+
client: nio.AsyncClient,
|
|
184
|
+
workflow: ScheduledWorkflow,
|
|
185
|
+
config: Config,
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Execute a scheduled workflow by posting its message to the thread."""
|
|
188
|
+
if not workflow.room_id:
|
|
189
|
+
logger.error("Cannot execute workflow without room_id")
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
automated_message = (
|
|
194
|
+
f"⏰ [Automated Task]\n{workflow.message}\n\n_Note: Automated task - no follow-up expected._"
|
|
195
|
+
)
|
|
196
|
+
latest_thread_event_id = await get_latest_thread_event_id_if_needed(
|
|
197
|
+
client,
|
|
198
|
+
workflow.room_id,
|
|
199
|
+
workflow.thread_id,
|
|
200
|
+
)
|
|
201
|
+
content = format_message_with_mentions(
|
|
202
|
+
config,
|
|
203
|
+
automated_message,
|
|
204
|
+
sender_domain=config.domain,
|
|
205
|
+
thread_event_id=workflow.thread_id,
|
|
206
|
+
latest_thread_event_id=latest_thread_event_id,
|
|
207
|
+
)
|
|
208
|
+
await send_message(client, workflow.room_id, content)
|
|
209
|
+
logger.info("Executed scheduled workflow", description=workflow.description, thread_id=workflow.thread_id)
|
|
210
|
+
except Exception as e:
|
|
211
|
+
logger.exception("Failed to execute scheduled workflow")
|
|
212
|
+
if workflow.room_id:
|
|
213
|
+
error_message = f"❌ Scheduled task failed: {workflow.description}\nError: {e!s}"
|
|
214
|
+
error_content = build_message_content(
|
|
215
|
+
body=error_message,
|
|
216
|
+
thread_event_id=workflow.thread_id,
|
|
217
|
+
latest_thread_event_id=workflow.thread_id,
|
|
218
|
+
)
|
|
219
|
+
await send_message(client, workflow.room_id, error_content)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
async def run_cron_task(
|
|
223
|
+
client: nio.AsyncClient,
|
|
224
|
+
task_id: str,
|
|
225
|
+
workflow: ScheduledWorkflow,
|
|
226
|
+
running_tasks: dict[str, asyncio.Task],
|
|
227
|
+
config: Config,
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Run a recurring task based on cron schedule."""
|
|
230
|
+
if not workflow.cron_schedule:
|
|
231
|
+
logger.error("No cron schedule provided for recurring task")
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
cron_string = workflow.cron_schedule.to_cron_string()
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
cron = croniter(cron_string, datetime.now(UTC))
|
|
238
|
+
while True:
|
|
239
|
+
next_run = cron.get_next(datetime)
|
|
240
|
+
delay = (next_run - datetime.now(UTC)).total_seconds()
|
|
241
|
+
if delay > 0:
|
|
242
|
+
logger.info(
|
|
243
|
+
f"Waiting {delay:.0f} seconds until next execution",
|
|
244
|
+
task_id=task_id,
|
|
245
|
+
next_run=next_run.isoformat(),
|
|
246
|
+
)
|
|
247
|
+
await asyncio.sleep(delay)
|
|
248
|
+
await execute_scheduled_workflow(client, workflow, config)
|
|
249
|
+
if task_id not in running_tasks:
|
|
250
|
+
logger.info(f"Task {task_id} no longer in running tasks, stopping")
|
|
251
|
+
break
|
|
252
|
+
except asyncio.CancelledError:
|
|
253
|
+
logger.info(f"Cron task {task_id} was cancelled")
|
|
254
|
+
raise
|
|
255
|
+
except Exception as e:
|
|
256
|
+
logger.exception(f"Error in cron task {task_id}")
|
|
257
|
+
if workflow.room_id:
|
|
258
|
+
error_message = f"❌ Recurring task failed: {workflow.description}\nTask ID: {task_id}\nError: {e!s}"
|
|
259
|
+
error_content = build_message_content(
|
|
260
|
+
body=error_message,
|
|
261
|
+
thread_event_id=workflow.thread_id,
|
|
262
|
+
latest_thread_event_id=workflow.thread_id,
|
|
263
|
+
)
|
|
264
|
+
await send_message(client, workflow.room_id, error_content)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
async def run_once_task(
|
|
268
|
+
client: nio.AsyncClient,
|
|
269
|
+
task_id: str,
|
|
270
|
+
workflow: ScheduledWorkflow,
|
|
271
|
+
config: Config,
|
|
272
|
+
) -> None:
|
|
273
|
+
"""Run a one-time scheduled task."""
|
|
274
|
+
if not workflow.execute_at:
|
|
275
|
+
logger.error("No execution time provided for one-time task")
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
delay = (workflow.execute_at - datetime.now(UTC)).total_seconds()
|
|
280
|
+
if delay > 0:
|
|
281
|
+
logger.info(
|
|
282
|
+
f"Waiting {delay:.0f} seconds until execution",
|
|
283
|
+
task_id=task_id,
|
|
284
|
+
execute_at=workflow.execute_at.isoformat(),
|
|
285
|
+
)
|
|
286
|
+
await asyncio.sleep(delay)
|
|
287
|
+
await execute_scheduled_workflow(client, workflow, config)
|
|
288
|
+
except asyncio.CancelledError:
|
|
289
|
+
logger.info(f"One-time task {task_id} was cancelled")
|
|
290
|
+
raise
|
|
291
|
+
except Exception as e:
|
|
292
|
+
logger.exception(f"Error in one-time task {task_id}")
|
|
293
|
+
if workflow.room_id:
|
|
294
|
+
error_message = f"❌ One-time task failed: {workflow.description}\nTask ID: {task_id}\nError: {e!s}"
|
|
295
|
+
error_content = build_message_content(
|
|
296
|
+
body=error_message,
|
|
297
|
+
thread_event_id=workflow.thread_id,
|
|
298
|
+
latest_thread_event_id=workflow.thread_id,
|
|
299
|
+
)
|
|
300
|
+
await send_message(client, workflow.room_id, error_content)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
async def _validate_agent_mentions(
|
|
304
|
+
message: str,
|
|
305
|
+
room: nio.MatrixRoom,
|
|
306
|
+
thread_id: str | None,
|
|
307
|
+
config: Config,
|
|
308
|
+
) -> _AgentValidationResult:
|
|
309
|
+
"""Validate that all mentioned agents are accessible.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
message: The message that may contain @agent mentions
|
|
313
|
+
room: The Matrix room object
|
|
314
|
+
thread_id: The thread ID where the schedule will execute (if in a thread)
|
|
315
|
+
config: Application configuration
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
_AgentValidationResult with validation status and agent lists
|
|
319
|
+
|
|
320
|
+
"""
|
|
321
|
+
# Parse mentions - this handles all the agent name resolution properly
|
|
322
|
+
_, mentioned_user_ids, _ = parse_mentions_in_text(message, config.domain, config)
|
|
323
|
+
|
|
324
|
+
if not mentioned_user_ids:
|
|
325
|
+
# No agents mentioned, validation passes
|
|
326
|
+
return _AgentValidationResult(all_valid=True, valid_agents=[], invalid_agents=[])
|
|
327
|
+
|
|
328
|
+
# Extract agent names from the mentioned user IDs
|
|
329
|
+
|
|
330
|
+
mentioned_agents: list[MatrixID] = []
|
|
331
|
+
for user_id in mentioned_user_ids:
|
|
332
|
+
mid = MatrixID.parse(user_id)
|
|
333
|
+
agent_name = mid.agent_name(config)
|
|
334
|
+
if agent_name and mid not in mentioned_agents:
|
|
335
|
+
mentioned_agents.append(mid)
|
|
336
|
+
|
|
337
|
+
if not mentioned_agents:
|
|
338
|
+
# No valid agents mentioned
|
|
339
|
+
return _AgentValidationResult(all_valid=True, valid_agents=[], invalid_agents=[])
|
|
340
|
+
|
|
341
|
+
valid_agents: list[MatrixID] = []
|
|
342
|
+
invalid_agents: list[MatrixID] = []
|
|
343
|
+
|
|
344
|
+
if thread_id:
|
|
345
|
+
# For threads, check if agents are in the room
|
|
346
|
+
room_agents = get_available_agents_in_room(room, config)
|
|
347
|
+
|
|
348
|
+
# Agents can now respond in any room they're in
|
|
349
|
+
for mid in mentioned_agents:
|
|
350
|
+
if mid in room_agents:
|
|
351
|
+
valid_agents.append(mid)
|
|
352
|
+
else:
|
|
353
|
+
invalid_agents.append(mid)
|
|
354
|
+
else:
|
|
355
|
+
# For room messages, check if agents are configured for the room
|
|
356
|
+
room_agents = get_available_agents_in_room(room, config)
|
|
357
|
+
for mid in mentioned_agents:
|
|
358
|
+
if mid in room_agents:
|
|
359
|
+
valid_agents.append(mid)
|
|
360
|
+
else:
|
|
361
|
+
invalid_agents.append(mid)
|
|
362
|
+
|
|
363
|
+
all_valid = len(invalid_agents) == 0
|
|
364
|
+
return _AgentValidationResult(
|
|
365
|
+
all_valid=all_valid,
|
|
366
|
+
valid_agents=valid_agents,
|
|
367
|
+
invalid_agents=invalid_agents,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _format_scheduled_time(dt: datetime, timezone_str: str) -> str:
|
|
372
|
+
"""Format a datetime with timezone and relative time delta.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
dt: Datetime in UTC
|
|
376
|
+
timezone_str: Timezone string (e.g., 'America/New_York')
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
Formatted string like "2024-01-15 3:30 PM EST (in 2 hours)"
|
|
380
|
+
|
|
381
|
+
"""
|
|
382
|
+
# Convert UTC to target timezone
|
|
383
|
+
tz = ZoneInfo(timezone_str)
|
|
384
|
+
local_dt = dt.astimezone(tz)
|
|
385
|
+
|
|
386
|
+
# Get human-readable relative time using humanize
|
|
387
|
+
now = datetime.now(UTC)
|
|
388
|
+
relative_str = humanize.naturaltime(dt, when=now)
|
|
389
|
+
|
|
390
|
+
# Format the datetime string with 24-hour time
|
|
391
|
+
time_str = local_dt.strftime("%Y-%m-%d %H:%M %Z")
|
|
392
|
+
return f"{time_str} ({relative_str})"
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
async def schedule_task( # noqa: C901, PLR0912, PLR0915
|
|
396
|
+
client: nio.AsyncClient,
|
|
397
|
+
room_id: str,
|
|
398
|
+
thread_id: str | None,
|
|
399
|
+
scheduled_by: str,
|
|
400
|
+
full_text: str,
|
|
401
|
+
config: Config,
|
|
402
|
+
room: nio.MatrixRoom,
|
|
403
|
+
mentioned_agents: list[MatrixID] | None = None,
|
|
404
|
+
) -> tuple[str | None, str]:
|
|
405
|
+
"""Schedule a workflow from natural language request.
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
Tuple of (task_id, response_message)
|
|
409
|
+
|
|
410
|
+
"""
|
|
411
|
+
# Get agents that are available in the thread
|
|
412
|
+
available_agents: list[MatrixID] = []
|
|
413
|
+
if thread_id:
|
|
414
|
+
# Get agents already participating in the thread
|
|
415
|
+
thread_history = await fetch_thread_history(client, room_id, thread_id)
|
|
416
|
+
available_agents = get_agents_in_thread(thread_history, config)
|
|
417
|
+
|
|
418
|
+
# Add any agents mentioned in the command itself
|
|
419
|
+
if mentioned_agents:
|
|
420
|
+
for mid in mentioned_agents:
|
|
421
|
+
if mid not in available_agents:
|
|
422
|
+
available_agents.append(mid)
|
|
423
|
+
|
|
424
|
+
# If no agents found in thread or mentions, fall back to agents in the room
|
|
425
|
+
if not available_agents:
|
|
426
|
+
available_agents = get_available_agents_in_room(room, config)
|
|
427
|
+
|
|
428
|
+
# Parse the workflow request with available agents
|
|
429
|
+
workflow_result = await parse_workflow_schedule(full_text, config, available_agents)
|
|
430
|
+
|
|
431
|
+
if isinstance(workflow_result, WorkflowParseError):
|
|
432
|
+
error_msg = f"❌ {workflow_result.error}"
|
|
433
|
+
if workflow_result.suggestion:
|
|
434
|
+
error_msg += f"\n\n💡 {workflow_result.suggestion}"
|
|
435
|
+
return (None, error_msg)
|
|
436
|
+
|
|
437
|
+
# Handle workflow task
|
|
438
|
+
# Validate workflow before proceeding
|
|
439
|
+
if workflow_result.schedule_type == "once" and not workflow_result.execute_at:
|
|
440
|
+
return (None, "❌ Failed to schedule: One-time task missing execution time")
|
|
441
|
+
if workflow_result.schedule_type == "cron" and not workflow_result.cron_schedule:
|
|
442
|
+
return (None, "❌ Failed to schedule: Recurring task missing cron schedule")
|
|
443
|
+
|
|
444
|
+
# Validate that all mentioned agents are accessible
|
|
445
|
+
validation_result = await _validate_agent_mentions(workflow_result.message, room, thread_id, config)
|
|
446
|
+
|
|
447
|
+
if not validation_result.all_valid:
|
|
448
|
+
error_msg = "❌ Failed to schedule: The following agents are not available in this "
|
|
449
|
+
if thread_id:
|
|
450
|
+
error_msg += "thread"
|
|
451
|
+
else:
|
|
452
|
+
error_msg += "room"
|
|
453
|
+
error_msg += f": {', '.join(agent.full_id for agent in validation_result.invalid_agents)}"
|
|
454
|
+
|
|
455
|
+
# Provide helpful suggestions
|
|
456
|
+
suggestions: list[str] = []
|
|
457
|
+
for agent in validation_result.invalid_agents:
|
|
458
|
+
agent_name = agent.agent_name(config)
|
|
459
|
+
if agent_name:
|
|
460
|
+
# Agent exists but not available in this room/thread
|
|
461
|
+
suggestions.append(f"{agent.full_id} is not available in this {'thread' if thread_id else 'room'}")
|
|
462
|
+
else:
|
|
463
|
+
suggestions.append(f"{agent.full_id} does not exist")
|
|
464
|
+
|
|
465
|
+
if suggestions:
|
|
466
|
+
error_msg += "\n\n💡 " + "\n💡 ".join(suggestions)
|
|
467
|
+
|
|
468
|
+
return (None, error_msg)
|
|
469
|
+
|
|
470
|
+
# Add metadata to workflow
|
|
471
|
+
workflow_result.created_by = scheduled_by
|
|
472
|
+
workflow_result.thread_id = thread_id
|
|
473
|
+
workflow_result.room_id = room_id
|
|
474
|
+
|
|
475
|
+
# Create task ID
|
|
476
|
+
task_id = str(uuid.uuid4())[:8]
|
|
477
|
+
|
|
478
|
+
# Store workflow in Matrix state
|
|
479
|
+
task_data = {
|
|
480
|
+
"task_id": task_id,
|
|
481
|
+
"workflow": workflow_result.model_dump_json(),
|
|
482
|
+
"status": "pending",
|
|
483
|
+
"created_at": datetime.now(UTC).isoformat(),
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
logger.info(
|
|
487
|
+
"Storing workflow task in Matrix state",
|
|
488
|
+
task_id=task_id,
|
|
489
|
+
room_id=room_id,
|
|
490
|
+
thread_id=thread_id,
|
|
491
|
+
schedule_type=workflow_result.schedule_type,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
await client.room_put_state(
|
|
495
|
+
room_id=room_id,
|
|
496
|
+
event_type=SCHEDULED_TASK_EVENT_TYPE,
|
|
497
|
+
content=task_data,
|
|
498
|
+
state_key=task_id,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
# Start the appropriate async task
|
|
502
|
+
if workflow_result.schedule_type == "once":
|
|
503
|
+
task = asyncio.create_task(
|
|
504
|
+
run_once_task(client, task_id, workflow_result, config),
|
|
505
|
+
)
|
|
506
|
+
else: # cron
|
|
507
|
+
task = asyncio.create_task(
|
|
508
|
+
run_cron_task(client, task_id, workflow_result, _running_tasks, config),
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
_running_tasks[task_id] = task
|
|
512
|
+
|
|
513
|
+
# Build success message
|
|
514
|
+
if workflow_result.schedule_type == "once" and workflow_result.execute_at:
|
|
515
|
+
# Format time with timezone and relative delta
|
|
516
|
+
formatted_time = _format_scheduled_time(workflow_result.execute_at, config.timezone)
|
|
517
|
+
success_msg = f"✅ Scheduled for {formatted_time}\n"
|
|
518
|
+
elif workflow_result.cron_schedule:
|
|
519
|
+
# Show both natural language and cron syntax
|
|
520
|
+
natural_desc = workflow_result.cron_schedule.to_natural_language()
|
|
521
|
+
cron_str = workflow_result.cron_schedule.to_cron_string()
|
|
522
|
+
success_msg = f"✅ Scheduled recurring task: **{natural_desc}**\n"
|
|
523
|
+
success_msg += f" _(Cron: `{cron_str}`)_\n"
|
|
524
|
+
else:
|
|
525
|
+
success_msg = "✅ Task scheduled\n"
|
|
526
|
+
|
|
527
|
+
success_msg += f"\n**Task:** {workflow_result.description}\n"
|
|
528
|
+
success_msg += f"**Will post:** {workflow_result.message}\n"
|
|
529
|
+
success_msg += f"\n**Task ID:** `{task_id}`"
|
|
530
|
+
|
|
531
|
+
return (task_id, success_msg)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
async def list_scheduled_tasks( # noqa: C901, PLR0912
|
|
535
|
+
client: nio.AsyncClient,
|
|
536
|
+
room_id: str,
|
|
537
|
+
thread_id: str | None = None,
|
|
538
|
+
config: Config | None = None,
|
|
539
|
+
) -> str:
|
|
540
|
+
"""List scheduled tasks in human-readable format."""
|
|
541
|
+
response = await client.room_get_state(room_id)
|
|
542
|
+
|
|
543
|
+
if not isinstance(response, nio.RoomGetStateResponse):
|
|
544
|
+
logger.error("Failed to get room state", response=str(response), room_id=room_id, thread_id=thread_id)
|
|
545
|
+
return "Unable to retrieve scheduled tasks."
|
|
546
|
+
|
|
547
|
+
tasks = []
|
|
548
|
+
tasks_in_other_threads = []
|
|
549
|
+
|
|
550
|
+
for event in response.events:
|
|
551
|
+
if event["type"] == SCHEDULED_TASK_EVENT_TYPE:
|
|
552
|
+
content = event["content"]
|
|
553
|
+
if content.get("status") == "pending":
|
|
554
|
+
try:
|
|
555
|
+
# Parse the workflow
|
|
556
|
+
workflow_data = json.loads(content["workflow"])
|
|
557
|
+
workflow = ScheduledWorkflow(**workflow_data)
|
|
558
|
+
|
|
559
|
+
# Determine display time
|
|
560
|
+
if workflow.schedule_type == "once" and workflow.execute_at:
|
|
561
|
+
display_time = workflow.execute_at
|
|
562
|
+
schedule_type = "once"
|
|
563
|
+
else:
|
|
564
|
+
# For cron, show the natural language description
|
|
565
|
+
display_time = None
|
|
566
|
+
if workflow.cron_schedule:
|
|
567
|
+
schedule_type = workflow.cron_schedule.to_natural_language()
|
|
568
|
+
else:
|
|
569
|
+
schedule_type = "recurring"
|
|
570
|
+
|
|
571
|
+
task_info = {
|
|
572
|
+
"id": event["state_key"],
|
|
573
|
+
"time": display_time,
|
|
574
|
+
"schedule_type": schedule_type,
|
|
575
|
+
"description": workflow.description,
|
|
576
|
+
"message": workflow.message,
|
|
577
|
+
"thread_id": workflow.thread_id,
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
# Separate tasks by thread
|
|
581
|
+
if thread_id and workflow.thread_id and workflow.thread_id != thread_id:
|
|
582
|
+
tasks_in_other_threads.append(task_info)
|
|
583
|
+
else:
|
|
584
|
+
tasks.append(task_info)
|
|
585
|
+
except (KeyError, ValueError, json.JSONDecodeError):
|
|
586
|
+
logger.exception("Failed to parse task")
|
|
587
|
+
continue
|
|
588
|
+
|
|
589
|
+
if not tasks and not tasks_in_other_threads:
|
|
590
|
+
return "No scheduled tasks found."
|
|
591
|
+
|
|
592
|
+
if not tasks and tasks_in_other_threads:
|
|
593
|
+
return f"No scheduled tasks in this thread.\n\n📌 {len(tasks_in_other_threads)} task(s) scheduled in other threads. Use !list_schedules in those threads to see details."
|
|
594
|
+
|
|
595
|
+
# Sort by execution time (one-time tasks) or put recurring tasks at the end
|
|
596
|
+
tasks.sort(key=lambda t: (t["time"] is None, t["time"] or datetime.max.replace(tzinfo=UTC)))
|
|
597
|
+
|
|
598
|
+
lines = ["**Scheduled Tasks:**"]
|
|
599
|
+
for task in tasks:
|
|
600
|
+
if task["schedule_type"] == "once" and task["time"]:
|
|
601
|
+
# Get timezone from config or use UTC as fallback
|
|
602
|
+
timezone = config.timezone if config else "UTC"
|
|
603
|
+
time_str = _format_scheduled_time(task["time"], timezone)
|
|
604
|
+
else:
|
|
605
|
+
# For recurring tasks, schedule_type now contains the natural language description
|
|
606
|
+
time_str = task["schedule_type"]
|
|
607
|
+
|
|
608
|
+
msg_preview = task["message"][:MESSAGE_PREVIEW_LENGTH] + (
|
|
609
|
+
"..." if len(task["message"]) > MESSAGE_PREVIEW_LENGTH else ""
|
|
610
|
+
)
|
|
611
|
+
lines.append(f'• `{task["id"]}` - {time_str}\n {task["description"]}\n Message: "{msg_preview}"')
|
|
612
|
+
|
|
613
|
+
return "\n".join(lines)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
async def cancel_scheduled_task(
|
|
617
|
+
client: nio.AsyncClient,
|
|
618
|
+
room_id: str,
|
|
619
|
+
task_id: str,
|
|
620
|
+
) -> str:
|
|
621
|
+
"""Cancel a scheduled task."""
|
|
622
|
+
# Cancel the asyncio task if running
|
|
623
|
+
if task_id in _running_tasks:
|
|
624
|
+
_running_tasks[task_id].cancel()
|
|
625
|
+
del _running_tasks[task_id]
|
|
626
|
+
|
|
627
|
+
# First check if task exists
|
|
628
|
+
response = await client.room_get_state_event(
|
|
629
|
+
room_id=room_id,
|
|
630
|
+
event_type=SCHEDULED_TASK_EVENT_TYPE,
|
|
631
|
+
state_key=task_id,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
if not isinstance(response, nio.RoomGetStateEventResponse):
|
|
635
|
+
return f"❌ Task `{task_id}` not found."
|
|
636
|
+
|
|
637
|
+
# Update to cancelled
|
|
638
|
+
await client.room_put_state(
|
|
639
|
+
room_id=room_id,
|
|
640
|
+
event_type=SCHEDULED_TASK_EVENT_TYPE,
|
|
641
|
+
content={"status": "cancelled"},
|
|
642
|
+
state_key=task_id,
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
return f"✅ Cancelled task `{task_id}`"
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
async def cancel_all_scheduled_tasks(
|
|
649
|
+
client: nio.AsyncClient,
|
|
650
|
+
room_id: str,
|
|
651
|
+
) -> str:
|
|
652
|
+
"""Cancel all scheduled tasks in a room."""
|
|
653
|
+
# Get all scheduled tasks
|
|
654
|
+
response = await client.room_get_state(room_id)
|
|
655
|
+
|
|
656
|
+
if not isinstance(response, nio.RoomGetStateResponse):
|
|
657
|
+
logger.error("Failed to get room state", response=str(response))
|
|
658
|
+
return "❌ Unable to retrieve scheduled tasks."
|
|
659
|
+
|
|
660
|
+
cancelled_count = 0
|
|
661
|
+
failed_count = 0
|
|
662
|
+
|
|
663
|
+
for event in response.events:
|
|
664
|
+
if event["type"] == SCHEDULED_TASK_EVENT_TYPE:
|
|
665
|
+
content = event["content"]
|
|
666
|
+
if content.get("status") == "pending":
|
|
667
|
+
task_id = event["state_key"]
|
|
668
|
+
|
|
669
|
+
# Cancel the asyncio task if running
|
|
670
|
+
if task_id in _running_tasks:
|
|
671
|
+
_running_tasks[task_id].cancel()
|
|
672
|
+
del _running_tasks[task_id]
|
|
673
|
+
|
|
674
|
+
# Update to cancelled in Matrix state
|
|
675
|
+
try:
|
|
676
|
+
await client.room_put_state(
|
|
677
|
+
room_id=room_id,
|
|
678
|
+
event_type=SCHEDULED_TASK_EVENT_TYPE,
|
|
679
|
+
content={"status": "cancelled"},
|
|
680
|
+
state_key=task_id,
|
|
681
|
+
)
|
|
682
|
+
cancelled_count += 1
|
|
683
|
+
logger.info(f"Cancelled task {task_id}")
|
|
684
|
+
except Exception:
|
|
685
|
+
logger.exception(f"Failed to cancel task {task_id}")
|
|
686
|
+
failed_count += 1
|
|
687
|
+
|
|
688
|
+
if cancelled_count == 0:
|
|
689
|
+
return "No scheduled tasks to cancel."
|
|
690
|
+
|
|
691
|
+
result = f"✅ Cancelled {cancelled_count} scheduled task(s)"
|
|
692
|
+
if failed_count > 0:
|
|
693
|
+
result += f"\n⚠️ Failed to cancel {failed_count} task(s)"
|
|
694
|
+
|
|
695
|
+
return result
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
async def restore_scheduled_tasks(client: nio.AsyncClient, room_id: str, config: Config) -> int: # noqa: C901, PLR0912
|
|
699
|
+
"""Restore scheduled tasks from Matrix state after bot restart.
|
|
700
|
+
|
|
701
|
+
Returns:
|
|
702
|
+
Number of tasks restored
|
|
703
|
+
|
|
704
|
+
"""
|
|
705
|
+
response = await client.room_get_state(room_id)
|
|
706
|
+
if not isinstance(response, nio.RoomGetStateResponse):
|
|
707
|
+
return 0
|
|
708
|
+
|
|
709
|
+
restored_count = 0
|
|
710
|
+
for event in response.events:
|
|
711
|
+
if event["type"] != SCHEDULED_TASK_EVENT_TYPE:
|
|
712
|
+
continue
|
|
713
|
+
|
|
714
|
+
content = event["content"]
|
|
715
|
+
if content.get("status") != "pending":
|
|
716
|
+
continue
|
|
717
|
+
|
|
718
|
+
try:
|
|
719
|
+
task_id: str = event["state_key"]
|
|
720
|
+
|
|
721
|
+
# Parse the workflow
|
|
722
|
+
workflow_data = json.loads(content["workflow"])
|
|
723
|
+
workflow = ScheduledWorkflow(**workflow_data)
|
|
724
|
+
|
|
725
|
+
# Validate workflow has required fields
|
|
726
|
+
if workflow.schedule_type == "once":
|
|
727
|
+
if not workflow.execute_at:
|
|
728
|
+
logger.warning(f"Skipping one-time task {task_id} without execution time")
|
|
729
|
+
continue
|
|
730
|
+
# Skip past one-time tasks
|
|
731
|
+
if workflow.execute_at <= datetime.now(UTC):
|
|
732
|
+
logger.debug(f"Skipping past one-time task {task_id}")
|
|
733
|
+
continue
|
|
734
|
+
elif workflow.schedule_type == "cron":
|
|
735
|
+
if not workflow.cron_schedule:
|
|
736
|
+
logger.warning(f"Skipping recurring task {task_id} without cron schedule")
|
|
737
|
+
continue
|
|
738
|
+
else:
|
|
739
|
+
logger.warning(f"Unknown schedule type for task {task_id}: {workflow.schedule_type}")
|
|
740
|
+
continue
|
|
741
|
+
|
|
742
|
+
# Start the appropriate task
|
|
743
|
+
if workflow.schedule_type == "once":
|
|
744
|
+
task = asyncio.create_task(run_once_task(client, task_id, workflow, config))
|
|
745
|
+
else:
|
|
746
|
+
task = asyncio.create_task(run_cron_task(client, task_id, workflow, _running_tasks, config))
|
|
747
|
+
|
|
748
|
+
_running_tasks[task_id] = task
|
|
749
|
+
restored_count += 1
|
|
750
|
+
|
|
751
|
+
except (KeyError, ValueError, json.JSONDecodeError):
|
|
752
|
+
logger.exception("Failed to restore task")
|
|
753
|
+
continue
|
|
754
|
+
|
|
755
|
+
if restored_count > 0:
|
|
756
|
+
logger.info("Restored scheduled tasks in room", room_id=room_id, restored_count=restored_count)
|
|
757
|
+
|
|
758
|
+
return restored_count
|