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:
+77
-63
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user