feat: scheduled reminders + less aggressive article summary
Add scheduled messages/reminders system: - New scheduled_messages table in memory-service with CRUD endpoints - schedule_message, list_reminders, cancel_reminder tools for the bot - Background scheduler loop (30s) sends due reminders automatically - Supports one-time, daily, weekly, weekdays, monthly repeat patterns Make article URL handling non-blocking: - Show 3 options (discuss, text summary, audio) instead of forcing audio wizard - Default to passing article context to AI if user just keeps chatting - New AWAITING_LANGUAGE state for cleaner audio flow FSM Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
239
bot.py
239
bot.py
@@ -114,7 +114,8 @@ IMPORTANT RULES — FOLLOW THESE STRICTLY:
|
||||
- When creating Jira issues, always confirm the project key and summary with the user before creating.
|
||||
- If a user's Atlassian account is not connected, tell them to connect it at matrixhost.eu/settings and provide the link.
|
||||
- If user memories are provided, use them to personalize responses. Address users by name if known.
|
||||
- When asked to translate, provide ONLY the translation with no explanation."""
|
||||
- When asked to translate, provide ONLY the translation with no explanation.
|
||||
- You can set reminders and scheduled messages. When users ask to be reminded of something, use the schedule_message tool. Parse natural language times like "in 2 hours", "tomorrow at 9am", "every Monday" into ISO 8601 datetime with Europe/Berlin timezone (unless user specifies otherwise)."""
|
||||
|
||||
IMAGE_GEN_TOOLS = [{
|
||||
"type": "function",
|
||||
@@ -334,7 +335,58 @@ ROOM_TOOLS = [{
|
||||
},
|
||||
}]
|
||||
|
||||
ALL_TOOLS = IMAGE_GEN_TOOLS + WEB_SEARCH_TOOLS + ATLASSIAN_TOOLS + ROOM_TOOLS
|
||||
SCHEDULER_TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "schedule_message",
|
||||
"description": (
|
||||
"Schedule a reminder or message to be sent at a future time. "
|
||||
"Use when the user says things like 'remind me', 'erinnere mich', "
|
||||
"'send a message at', 'every Monday at 8am', etc. "
|
||||
"Parse the user's natural language time into an ISO 8601 datetime. "
|
||||
"Default timezone is Europe/Berlin unless the user specifies otherwise."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {"type": "string", "description": "The reminder text to send"},
|
||||
"datetime_iso": {"type": "string", "description": "When to send, ISO 8601 format (e.g. 2026-03-10T09:00:00+01:00)"},
|
||||
"repeat": {
|
||||
"type": "string",
|
||||
"description": "Repeat pattern: 'once' (default), 'daily', 'weekly', 'weekdays', 'monthly'",
|
||||
"enum": ["once", "daily", "weekly", "weekdays", "monthly"],
|
||||
},
|
||||
},
|
||||
"required": ["message", "datetime_iso"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_reminders",
|
||||
"description": "List all active/pending reminders for the user. Use when they ask 'what reminders do I have?' or 'show my reminders'.",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "cancel_reminder",
|
||||
"description": "Cancel a scheduled reminder by its ID number. Use when the user says 'cancel reminder #3' or 'lösche Erinnerung 3'.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reminder_id": {"type": "integer", "description": "The reminder ID to cancel"},
|
||||
},
|
||||
"required": ["reminder_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
ALL_TOOLS = IMAGE_GEN_TOOLS + WEB_SEARCH_TOOLS + ATLASSIAN_TOOLS + ROOM_TOOLS + SCHEDULER_TOOLS
|
||||
|
||||
ATLASSIAN_NOT_CONNECTED_MSG = (
|
||||
"Your Atlassian account is not connected. "
|
||||
@@ -579,6 +631,95 @@ class MemoryClient:
|
||||
return []
|
||||
|
||||
|
||||
async def create_scheduled(self, user_id: str, room_id: str, message_text: str,
|
||||
scheduled_at: float, repeat: str = "once") -> dict:
|
||||
if not self.enabled:
|
||||
return {"error": "Memory service not configured"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/scheduled/create",
|
||||
json={
|
||||
"user_id": user_id, "room_id": room_id,
|
||||
"message_text": message_text, "scheduled_at": scheduled_at,
|
||||
"repeat_pattern": repeat,
|
||||
},
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
detail = e.response.json().get("detail", str(e)) if e.response else str(e)
|
||||
logger.warning("Schedule create failed: %s", detail)
|
||||
return {"error": detail}
|
||||
except Exception:
|
||||
logger.warning("Schedule create failed", exc_info=True)
|
||||
return {"error": "Failed to create reminder"}
|
||||
|
||||
async def list_scheduled(self, user_id: str) -> list[dict]:
|
||||
if not self.enabled:
|
||||
return []
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(
|
||||
f"{self.base_url}/scheduled/{user_id}",
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("reminders", [])
|
||||
except Exception:
|
||||
logger.warning("Schedule list failed", exc_info=True)
|
||||
return []
|
||||
|
||||
async def cancel_scheduled(self, user_id: str, reminder_id: int) -> dict:
|
||||
if not self.enabled:
|
||||
return {"error": "Memory service not configured"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.delete(
|
||||
f"{self.base_url}/scheduled/{user_id}/{reminder_id}",
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
detail = e.response.json().get("detail", str(e)) if e.response else str(e)
|
||||
return {"error": detail}
|
||||
except Exception:
|
||||
logger.warning("Schedule cancel failed", exc_info=True)
|
||||
return {"error": "Failed to cancel reminder"}
|
||||
|
||||
async def get_due_messages(self) -> list[dict]:
|
||||
if not self.enabled:
|
||||
return []
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/scheduled/due",
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("due", [])
|
||||
except Exception:
|
||||
logger.warning("Get due messages failed", exc_info=True)
|
||||
return []
|
||||
|
||||
async def mark_sent(self, reminder_id: int) -> dict:
|
||||
if not self.enabled:
|
||||
return {}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/scheduled/{reminder_id}/mark-sent",
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception:
|
||||
logger.warning("Mark sent failed for #%d", reminder_id, exc_info=True)
|
||||
return {}
|
||||
|
||||
|
||||
class AtlassianClient:
|
||||
"""Fetches per-user Atlassian tokens from the portal and calls Atlassian REST APIs."""
|
||||
|
||||
@@ -1114,6 +1255,9 @@ class Bot:
|
||||
self.client.add_to_device_callback(self.on_key_verification, KeyVerificationMac)
|
||||
self.client.add_to_device_callback(self.on_key_verification, KeyVerificationCancel)
|
||||
|
||||
# Start reminder scheduler
|
||||
asyncio.create_task(self._reminder_scheduler())
|
||||
|
||||
await self.client.sync_forever(timeout=30000, full_state=True)
|
||||
|
||||
async def _ensure_cross_signing(self):
|
||||
@@ -1257,6 +1401,25 @@ class Bot:
|
||||
except Exception as e:
|
||||
logger.error("RAG key injection failed: %s", e, exc_info=True)
|
||||
|
||||
async def _reminder_scheduler(self):
|
||||
"""Background loop: check for due reminders every 30 seconds, send them."""
|
||||
await asyncio.sleep(10) # Wait for bot to fully initialize
|
||||
logger.info("Reminder scheduler started")
|
||||
while True:
|
||||
try:
|
||||
due = await self.memory.get_due_messages()
|
||||
for msg in due:
|
||||
try:
|
||||
reminder_text = f"\u23f0 **Erinnerung:** {msg['message_text']}"
|
||||
await self._send_text(msg["room_id"], reminder_text)
|
||||
await self.memory.mark_sent(msg["id"])
|
||||
logger.info("Sent reminder #%d to %s", msg["id"], msg["room_id"])
|
||||
except Exception:
|
||||
logger.warning("Failed to send reminder #%d", msg["id"], exc_info=True)
|
||||
except Exception:
|
||||
logger.warning("Reminder scheduler check failed", exc_info=True)
|
||||
await asyncio.sleep(30)
|
||||
|
||||
async def on_invite(self, room, event: InviteMemberEvent):
|
||||
if event.state_key != BOT_USER:
|
||||
return
|
||||
@@ -1785,9 +1948,15 @@ class Bot:
|
||||
)
|
||||
finally:
|
||||
await self.client.room_typing(room.room_id, typing_state=False)
|
||||
return
|
||||
elif summary_response.startswith("__DISCUSS__"):
|
||||
# Extract article context, enrich the user message for AI
|
||||
article_info = summary_response[len("__DISCUSS__"):]
|
||||
body = f"[Article context: {article_info[:6000]}]\n\nUser message: {body}"
|
||||
# Fall through to normal AI handler with enriched context
|
||||
elif summary_response:
|
||||
await self._send_text(room.room_id, summary_response)
|
||||
return
|
||||
return
|
||||
|
||||
await self.client.room_typing(room.room_id, typing_state=True)
|
||||
try:
|
||||
@@ -2245,6 +2414,17 @@ class Bot:
|
||||
room_id, args.get("query", ""), args.get("limit", 200)
|
||||
)
|
||||
|
||||
# Scheduler tools — no Atlassian auth needed
|
||||
if tool_name == "schedule_message":
|
||||
return await self._schedule_message(
|
||||
sender, room_id, args.get("message", ""),
|
||||
args.get("datetime_iso", ""), args.get("repeat", "once")
|
||||
)
|
||||
if tool_name == "list_reminders":
|
||||
return await self._list_reminders(sender)
|
||||
if tool_name == "cancel_reminder":
|
||||
return await self._cancel_reminder(sender, args.get("reminder_id", 0))
|
||||
|
||||
# Atlassian tools — need per-user token
|
||||
token = await self.atlassian.get_token(sender) if sender else None
|
||||
if not token:
|
||||
@@ -2319,6 +2499,59 @@ class Bot:
|
||||
logger.warning("Room history search failed", exc_info=True)
|
||||
return "Failed to search room history."
|
||||
|
||||
async def _schedule_message(self, sender: str, room_id: str,
|
||||
message: str, datetime_iso: str, repeat: str) -> str:
|
||||
"""Parse datetime, validate, store via memory service."""
|
||||
if not message:
|
||||
return "No reminder message provided."
|
||||
if not datetime_iso:
|
||||
return "No datetime provided."
|
||||
try:
|
||||
from datetime import datetime as dt, timezone
|
||||
parsed = dt.fromisoformat(datetime_iso)
|
||||
if parsed.tzinfo is None:
|
||||
# Default to Europe/Berlin
|
||||
import zoneinfo
|
||||
parsed = parsed.replace(tzinfo=zoneinfo.ZoneInfo("Europe/Berlin"))
|
||||
ts = parsed.timestamp()
|
||||
except Exception:
|
||||
return f"Could not parse datetime: {datetime_iso}. Use ISO 8601 format."
|
||||
|
||||
if ts <= time.time():
|
||||
return "That time has already passed. Please specify a future time."
|
||||
|
||||
result = await self.memory.create_scheduled(sender, room_id, message, ts, repeat or "once")
|
||||
if "error" in result:
|
||||
return f"Failed to create reminder: {result['error']}"
|
||||
|
||||
time_str = parsed.strftime("%B %d, %Y at %H:%M")
|
||||
tz_name = str(parsed.tzinfo) if parsed.tzinfo else "Europe/Berlin"
|
||||
repeat_str = f" (repeats {repeat})" if repeat and repeat != "once" else ""
|
||||
return f"Reminder #{result.get('id', '?')} set for {time_str} ({tz_name}){repeat_str}: {message}"
|
||||
|
||||
async def _list_reminders(self, sender: str) -> str:
|
||||
"""List user's active reminders."""
|
||||
reminders = await self.memory.list_scheduled(sender)
|
||||
if not reminders:
|
||||
return "You have no active reminders."
|
||||
from datetime import datetime as dt, timezone
|
||||
lines = []
|
||||
for r in reminders:
|
||||
t = dt.fromtimestamp(r["scheduled_at"], tz=timezone.utc)
|
||||
time_str = t.strftime("%Y-%m-%d %H:%M UTC")
|
||||
repeat = f" ({r['repeat_pattern']})" if r["repeat_pattern"] != "once" else ""
|
||||
lines.append(f"**#{r['id']}** — {time_str}{repeat}: {r['message_text']}")
|
||||
return f"**Your reminders ({len(lines)}):**\n" + "\n".join(lines)
|
||||
|
||||
async def _cancel_reminder(self, sender: str, reminder_id: int) -> str:
|
||||
"""Cancel a reminder by ID."""
|
||||
if not reminder_id:
|
||||
return "Please provide a reminder ID to cancel."
|
||||
result = await self.memory.cancel_scheduled(sender, reminder_id)
|
||||
if "error" in result:
|
||||
return f"Could not cancel reminder #{reminder_id}: {result['error']}"
|
||||
return f"Reminder #{reminder_id} cancelled."
|
||||
|
||||
# -- Escalation patterns for model routing --
|
||||
_ESCALATION_KEYWORDS = re.compile(
|
||||
r"\b(debug|architecture|algorithm|regex|sql|refactor|optimize|migration"
|
||||
|
||||
Reference in New Issue
Block a user