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:
Christian Gick
2026-03-09 06:55:14 +02:00
parent 19abea01ca
commit 964a3f6075
4 changed files with 558 additions and 8 deletions

239
bot.py
View File

@@ -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"