Per-message Cronicle events with recurrence-aware timing

Instead of an every-minute poller, each scheduled message gets its own
Cronicle event created at schedule time:
- One-time: year/month/day/hour/minute one-shot
- Daily/weekly/monthly: recurring timing matching the recurrence setting

Retry (PUT with new scheduled_at on failed messages) also creates a new
Cronicle event and resets status to pending. Recurring messages reuse
their existing Cronicle event across occurrences.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 08:32:22 -04:00
parent 171410cf26
commit 790a7059ce
3 changed files with 95 additions and 69 deletions
+77 -63
View File
@@ -1,12 +1,11 @@
import logging
import os
from datetime import datetime, timezone
import httpx
logger = logging.getLogger(__name__)
_SCHEDULED_MESSAGES_TITLE = "Scheduled Messages Trigger"
def _base_url() -> str:
return os.environ.get("CRONICLE_URL", "").rstrip("/")
@@ -16,18 +15,8 @@ def _api_key() -> str:
return os.environ.get("CRONICLE_API_KEY", "")
async def _get_events() -> list[dict]:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{_base_url()}/api/app/get_schedule/v1",
params={"api_key": _api_key(), "limit": 100},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise RuntimeError(f"Cronicle get_schedule error: {data}")
return data.get("rows", [])
def _is_configured() -> bool:
return bool(_base_url() and _api_key())
async def _get_first_category_id() -> str:
@@ -47,12 +36,9 @@ async def _get_first_category_id() -> str:
return rows[0]["id"]
def _make_trigger_script(cron_secret: str) -> str:
"""Node.js script that tries raggr-green then falls back to raggr-blue.
Uses Node's built-in http module — no curl/wget needed, and node is always
present since Cronicle itself runs on Node.
"""
def _make_trigger_script() -> str:
"""Node.js script that tries raggr-green then falls back to raggr-blue."""
cron_secret = os.environ.get("CRON_SECRET", "")
path = "/api/scheduled-messages/trigger"
return f"""#!/usr/bin/env node
const http = require('http');
@@ -75,56 +61,84 @@ post('raggr-green').catch(() => post('raggr-blue')).catch(err => {{
"""
async def _create_event(title: str, script: str, category_id: str) -> str:
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{_base_url()}/api/app/create_event/v1",
json={
"api_key": _api_key(),
"title": title,
"enabled": 1,
"plugin": "shellplug",
"target": "allgrp",
"category": category_id,
# Every minute: list all 60 explicitly (Cronicle {} defaults to hourly)
"timing": {"minutes": list(range(60))},
"params": {"script": script},
"notes": "Auto-registered by raggr on startup",
},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise RuntimeError(f"Cronicle create_event error: {data}")
return data["id"]
def _build_timing(scheduled_at: datetime, recurrence: str) -> dict:
"""Build a Cronicle timing object from a scheduled datetime and recurrence.
async def ensure_cronicle_events() -> None:
"""Ensure the scheduled messages trigger event exists in Cronicle.
Skips silently if CRONICLE_URL or CRONICLE_API_KEY are not set.
- none: one-shot (fires exactly once at the given UTC datetime)
- daily: fires every day at the same hour/minute
- weekly: fires every week on the same weekday and hour/minute
- monthly: fires every month on the same day-of-month and hour/minute
"""
if not _base_url() or not _api_key():
logger.debug("CRONICLE_URL/CRONICLE_API_KEY not set — skipping registration")
return
utc = scheduled_at.astimezone(timezone.utc)
h, m = utc.hour, utc.minute
cron_secret = os.environ.get("CRON_SECRET", "")
if recurrence == "daily":
return {"hours": [h], "minutes": [m]}
elif recurrence == "weekly":
# weekday: 0=Sunday in Cronicle (Python isoweekday: Mon=1..Sun=7)
wd = utc.isoweekday() % 7 # convert to 0=Sunday
return {"weekdays": [wd], "hours": [h], "minutes": [m]}
elif recurrence == "monthly":
return {"days": [utc.day], "hours": [h], "minutes": [m]}
else:
# one-shot
return {
"years": [utc.year],
"months": [utc.month],
"days": [utc.day],
"hours": [utc.hour],
"minutes": [utc.minute],
}
async def create_message_event(
msg_id: str, scheduled_at: datetime, recurrence: str = "none"
) -> str | None:
"""Create a Cronicle event for a scheduled message.
Uses one-shot timing for one-time messages, recurring timing otherwise.
Returns the Cronicle event ID, or None if Cronicle is not configured.
"""
if not _is_configured():
return None
try:
events = await _get_events()
if any(e.get("title") == _SCHEDULED_MESSAGES_TITLE for e in events):
logger.info("Cronicle: '%s' already registered", _SCHEDULED_MESSAGES_TITLE)
return
category_id = await _get_first_category_id()
script = _make_trigger_script(cron_secret)
event_id = await _create_event(_SCHEDULED_MESSAGES_TITLE, script, category_id)
logger.info(
"Cronicle: created '%s' (id=%s)", _SCHEDULED_MESSAGES_TITLE, event_id
)
timing = _build_timing(scheduled_at, recurrence)
script = _make_trigger_script()
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{_base_url()}/api/app/create_event/v1",
json={
"api_key": _api_key(),
"title": f"Scheduled Message {msg_id}",
"enabled": 1,
"plugin": "shellplug",
"target": "allgrp",
"category": category_id,
"timing": timing,
"timezone": "UTC",
"params": {"script": script},
"notes": f"Auto-created for scheduled message {msg_id} (recurrence={recurrence})",
},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise RuntimeError(f"Cronicle create_event error: {data}")
event_id = data["id"]
logger.info(
"Cronicle: created %s event %s for message %s",
recurrence,
event_id,
msg_id,
)
return event_id
except Exception:
logger.exception(
"Cronicle registration failed — scheduled messages will not fire"
"Failed to create Cronicle event for message %s — will not fire", msg_id
)
return None