feat(scheduler): add positional day-of-month and date bounds

Standard 5-field cron cannot express "the 4th Tuesday" or "the last
Friday" — patterns recurring nets need — so operators were hand-rolling
day-of-month lists that drift across months of different lengths.

APScheduler's day field already understands these ("last fri", "4th
tue"); the only obstacle is the space, which collides with crontab's
field separator. Accept "-" or "_" in its place and restore it before
handing the field over:

    0 19 last-fri * *          last Friday of the month
    0 19 4th-tue * *           fourth Tuesday
    0 19 1st-tue,3rd-tue * *   first and third Tuesday

Neither separator is ambiguous — crontab range endpoints are digits, so
"last-fri" cannot read as a range. Positional expressions are valid only
in day-of-month; "0 19 * * last-fri" is rejected.

Also adds optional start=/end= date bounds, which limit a schedule to a
date range. They live on the option value rather than the key, ahead of
the channel and keyed with "=", so they never collide with the ":"
separating channel from message, and a body mentioning "start=" is not
misread as a bound. Either may be omitted, order does not matter, and
the end date is inclusive of that whole day. A schedule with no runs
left is skipped at startup with a log line saying why.

The web viewer gains start/end pickers, shows a bounded entry's window
and marks an exhausted one Finished. Bounds round-trip through the edit
cycle, so editing an entry cannot silently drop them.

Expressions without a positional escape are passed to APScheduler
untouched: a 6000-expression sweep over valid crontab forms shows no
behavioural change.
This commit is contained in:
Adam Gessaman
2026-08-29 13:34:05 -07:00
parent 4b858e2274
commit abd39acfa3
10 changed files with 550 additions and 29 deletions
+21
View File
@@ -6,6 +6,27 @@ semantic versioning.
## [Unreleased]
### Added
- **Nth and last weekday of the month in `[Scheduled_Messages]` keys.** The day-of-month
field now accepts APScheduler's positional expressions with `-` (or `_`) standing in for
the space that would otherwise split the crontab fields: `0 19 last-fri * *` is the last
Friday, `0 19 4th-tue * *` the fourth Tuesday, and `0 19 1st-tue,3rd-tue * *` the first
and third — patterns recurring nets need and plain 5-field cron cannot express, which
previously forced a hand-rolled day-of-month list that drifts across months of different
lengths. Expressions without one of these prefixes are passed to APScheduler untouched,
so every crontab form that worked before still parses identically. Positional
expressions are valid only in the day-of-month field; `0 19 * * last-fri` is rejected.
- **Optional `start=` / `end=` date bounds on `[Scheduled_Messages]` values**, for a
schedule that should only run over a date range: `0 19 last-fri * * = start=2027-01-01
end=2027-03-31 Public:Winter net`. They sit on the value rather than the schedule key,
ahead of the channel and keyed with `=`, so they never collide with the `:` separating
channel from message and a body mentioning `start=` is never misread as a bound. Either
may be omitted and order does not matter; the end date is inclusive of that whole day.
A schedule with no runs left is skipped at startup with a log line saying why, and the
web viewer's schedule page gains start/end pickers, shows a bounded entry's window and
marks an exhausted one **Finished**.
### Fixed
- `path` no longer answers "No path information available in current message" on a
+20
View File
@@ -509,11 +509,31 @@ category.funfact = fun
# 0 18 * * * = Public:Hello! ...
# 0 18 * * * = Public:#sea:Hello! ... (same channel text, sent with #sea flood scope)
#
# Optional date bounds limit a schedule to a range. They lead the value, ahead of the
# channel, and use "=" so they cannot be confused with the channel:message separator or
# with a message body that happens to mention "start=".
# <schedule> = start=YYYY-MM-DD end=YYYY-MM-DD channel:message
# Either may be omitted, and they may appear in either order. The end date is inclusive:
# "end=2027-03-31" runs through the whole of the 31st. A schedule with no runs left is
# skipped at startup with a log line saying so.
# Examples:
# 0 19 last-fri * * = start=2027-01-01 end=2027-03-31 Public:Winter net starts now
# 0 8 * * * = end=2026-12-25 Public:#sea:Countdown to the holidays
#
# <schedule> is one of:
# - 5-field crontab (minute hour day-of-month month day-of-week), e.g.
# 0 8 * * * = every day at 08:00 (in [Bot] timezone)
# 30 12 * * mon = every Monday at 12:30
# 30 12 * * 0 = same (Monday) — see APScheduler note below
# - Positional day-of-month, in the day-of-month field only. Write the space as
# "-" (or "_") so it does not read as a field separator:
# 0 19 last-fri * * = 19:00 on the last Friday of every month
# 0 19 4th-tue * * = 19:00 on the fourth Tuesday
# 0 19 1st-tue,3rd-tue * * = 19:00 on the first and third Tuesday
# 0 19 last * * = 19:00 on the last day of the month (no weekday)
# Prefixes are 1st 2nd 3rd 4th 5th last, followed by monsun. Note that a month
# may have no 5th Tuesday, and that "1st and 3rd" is a 14/14/21-day cadence, not
# a strict fortnight — crontab has no way to express "every 14 days".
# - Preset aliases (@yearly @annually @monthly @weekly @daily @midnight @hourly).
# These expand to the 5-field forms above; @weekly is Monday 00:00 (not Sunday).
# - Deprecated (still accepted, logs a warning): HHMM = daily at that 24h clock time
+41
View File
@@ -263,3 +263,44 @@ Schedule keys are parsed by **APScheduler** `CronTrigger.from_crontab` (plus `@`
Prefer **`mon``sun`** names in the DOW field so expressions stay unambiguous. Example: Monday 12:30 is `30 12 * * mon` or `30 12 * * 0` — **not** Vixies `30 12 * * 1` (that is Tuesday here).
Preset aliases expand to those same APScheduler forms. In particular **`@weekly`** is Monday 00:00 (`0 0 * * 0`), not Sunday midnight as on many Unix crons.
#### Limiting a schedule to a date range
Crontab has no field for a date range, so `start=` / `end=` bounds live on the **value**,
ahead of the channel:
```ini
[Scheduled_Messages]
0 19 last-fri * * = start=2027-01-01 end=2027-03-31 Public:Winter net starts now
0 8 * * * = end=2026-12-25 Public:#sea:Countdown to the holidays
```
Either bound may be omitted and they may appear in either order. `=` keeps them clear of
the `:` that separates channel from message, and because they are anchored to the front of
the value a message body mentioning `start=` is never mistaken for one.
The end date is **inclusive** — `end=2027-03-31` runs through the whole of the 31st, not up
to its midnight. Once a bounded schedule has no runs left the bot skips it at startup with
a log line saying so, and the web viewer shows it as **Finished** rather than hiding it.
#### Nth and last weekday of the month
APScheduler's day-of-month field understands positional expressions like `last fri` and
`4th tue`, which plain crontab cannot express. They contain a space, so write it as `-`
(or `_`) to keep the five fields intact:
| Key | Fires |
| --- | --- |
| `0 19 last-fri * *` | 19:00 on the last Friday of every month |
| `0 19 4th-tue * *` | 19:00 on the fourth Tuesday |
| `0 19 1st-tue,3rd-tue * *` | 19:00 on the first and third Tuesday |
| `0 19 last * *` | 19:00 on the last day of the month (no weekday needed) |
Prefixes are `1st` `2nd` `3rd` `4th` `5th` `last`, followed by `mon``sun`. They are valid
**only in the day-of-month field** — `0 19 * * last-fri` is rejected.
Two things to keep in mind. A month may have no fifth Tuesday, so `5th tue` simply skips
those months. And `1st-tue,3rd-tue` is a 14/14/21-day cadence across a month boundary, not
a strict fortnight: crontab matches calendar patterns and has no way to say "every 14
days". If you need an exact fortnight, note that anchoring one drifts an hour across a DST
change, which is usually worse for an announced net time than the 21-day gap.
+76 -13
View File
@@ -17,7 +17,11 @@ import configparser
import datetime
from typing import Any
from .scheduled_message_cron import parse_schedule_key, parse_scheduled_message_value
from .scheduled_message_cron import (
parse_schedule_key,
parse_scheduled_message_value,
split_schedule_bounds,
)
SECTION = "Scheduled_Messages"
@@ -55,7 +59,12 @@ def next_run_times(trigger: Any, tz: Any, count: int = 5) -> list[str]:
def describe_schedule(
schedule: str, tz: Any, message: str = "", count: int = 5
schedule: str,
tz: Any,
message: str = "",
count: int = 5,
start: str | None = None,
end: str | None = None,
) -> dict[str, Any]:
"""Validate a schedule key and describe when it would fire.
@@ -64,6 +73,8 @@ def describe_schedule(
tz: Timezone the bot schedules in.
message: Message body, only needed to apply the ``{cmd:...}`` airtime floor.
count: How many upcoming runs to return.
start: Optional ISO date the schedule starts on (from the entry's value).
end: Optional ISO date it runs through, inclusive.
Returns:
``valid``, a human ``label``, ``next_runs``, ``interval_seconds`` (tightest gap),
@@ -74,7 +85,7 @@ def describe_schedule(
return {"valid": False, "error": "Schedule is required", "next_runs": []}
try:
parsed = parse_schedule_key(raw, tz)
parsed = parse_schedule_key(raw, tz, start, end)
except Exception as exc: # noqa: BLE001 - any parser error is just an invalid schedule
return {"valid": False, "error": f"Could not parse schedule: {exc}", "next_runs": []}
@@ -83,7 +94,8 @@ def describe_schedule(
"valid": False,
"error": (
"Not a valid schedule. Use 5-field cron (minute hour day-of-month "
"month day-of-week), or a preset like @daily or @hourly."
"month day-of-week), a positional day-of-month such as last-fri or "
"4th-tue, or a preset like @daily or @hourly."
),
"next_runs": [],
}
@@ -97,11 +109,21 @@ def describe_schedule(
"deprecated": bool(parsed.is_deprecated_hhmm),
"error": None,
}
warnings: list[str] = []
if parsed.is_deprecated_hhmm:
result["warning"] = (
warnings.append(
f"{raw} is the deprecated HHMM form and will stop working in a future "
"release. Use 5-field cron instead."
)
if (start or end) and not result["next_runs"]:
# Well-formed, just outside its window -- distinct from a malformed schedule.
result["finished"] = True
warnings.append(
f"This schedule has no runs left: it is bounded to "
f"{start or 'any date'} .. {end or 'any date'}."
)
if warnings:
result["warning"] = " ".join(warnings)
# Same floor the scheduler enforces at startup, applied here so the UI refuses it
# up front rather than letting it be saved and silently dropped on reload.
@@ -131,20 +153,54 @@ def _humanize_seconds(seconds: float) -> str:
return f"{seconds} seconds"
def compose_value(channel: str, message: str, scope: str | None = None) -> str:
"""Build the config value for an entry, matching parse_scheduled_message_value."""
def compose_value(
channel: str,
message: str,
scope: str | None = None,
start: str | None = None,
end: str | None = None,
) -> str:
"""Build the config value for an entry, matching parse_scheduled_message_value.
Date bounds lead, so they cannot be confused with a message body, and are read
back off by :func:`~modules.scheduled_message_cron.split_schedule_bounds`.
"""
channel = (channel or "").strip()
message = (message or "").strip()
scope = (scope or "").strip()
starts_on = (start or "").strip()
ends_on = (end or "").strip()
prefix = ""
if starts_on:
prefix += f"start={starts_on} "
if ends_on:
prefix += f"end={ends_on} "
if scope:
if not scope.startswith("#"):
scope = f"#{scope}"
return f"{channel}:{scope}:{message}"
return f"{channel}:{message}"
return f"{prefix}{channel}:{scope}:{message}"
return f"{prefix}{channel}:{message}"
def validate_entry(channel: str, message: str, scope: str | None) -> str | None:
def validate_entry(
channel: str,
message: str,
scope: str | None,
start: str | None = None,
end: str | None = None,
) -> str | None:
"""Return an error string for an unusable entry, or None when it is fine."""
starts_on = (start or "").strip()
ends_on = (end or "").strip()
for label, value in (("Start date", starts_on), ("End date", ends_on)):
if not value:
continue
try:
datetime.date.fromisoformat(value)
except ValueError:
return f"{label} must be an ISO date (YYYY-MM-DD)"
if starts_on and ends_on and ends_on < starts_on:
return "End date is before the start date"
if not (channel or "").strip():
return "Channel is required"
if not (message or "").strip():
@@ -187,10 +243,15 @@ def read_entries(config_path: str, tz: Any) -> list[dict[str, Any]]:
"channel": "",
"scope": None,
"message": "",
"start": None,
"end": None,
}
try:
channel, message, scope = parse_scheduled_message_value(raw_value)
entry.update(channel=channel, message=message, scope=scope)
start, end, rest = split_schedule_bounds(raw_value)
channel, message, scope = parse_scheduled_message_value(rest)
entry.update(
channel=channel, message=message, scope=scope, start=start, end=end
)
except ValueError as exc:
# Keep the same shape as a described entry so callers never have to
# special-case a malformed row to find out it is not running.
@@ -199,6 +260,8 @@ def read_entries(config_path: str, tz: Any) -> list[dict[str, Any]]:
entry["next_runs"] = []
entries.append(entry)
continue
entry.update(describe_schedule(schedule, tz, message=message))
entry.update(
describe_schedule(schedule, tz, message=message, start=start, end=end)
)
entries.append(entry)
return entries
+110 -5
View File
@@ -3,8 +3,15 @@
Parse ``[Scheduled_Messages]`` option keys into APScheduler CronTrigger instances,
and option values into ``(channel, message, scope)`` for optional regional flood scope.
Option values may carry optional ``start=YYYY-MM-DD`` / ``end=YYYY-MM-DD`` bounds
ahead of the channel, which limit a schedule to a date range. They live on the value
because crontab has no field for them, and ``=`` keeps them clear of the ``:`` that
separates channel from message.
Supports (schedule keys):
- Standard 5-field crontab: minute hour day-of-month month day-of-week
- Positional day-of-month: ``last-fri``, ``4th-tue``, ``1st-mon,3rd-mon`` in the
day-of-month field, for patterns plain crontab cannot express
- Preset aliases: @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly
- Deprecated legacy HHMM (24-hour, no colon) for daily firing at that clock time
@@ -15,11 +22,54 @@ Day-of-week uses APScheduler numbering (0=Monday … 6=Sunday), not Vixie cron
from __future__ import annotations
import datetime
import re
from dataclasses import dataclass
from typing import Optional
from apscheduler.triggers.cron import CronTrigger
# Leading "start=YYYY-MM-DD" / "end=YYYY-MM-DD" tokens on an option value. Anchored to
# the front so a message body can never be mistaken for a bound, and keyed with "=" so
# they do not disturb the "channel:message" split.
_BOUND_RE = re.compile(r"^\s*(?P<key>start|end)=(?P<date>\S+)", re.IGNORECASE)
def split_schedule_bounds(raw: str) -> tuple[str | None, str | None, str]:
"""Strip leading ``start=`` / ``end=`` tokens off an option value.
Args:
raw: Config value, e.g. ``start=2027-01-01 Public:Hello`` or ``Public:Hello``.
Returns:
``(start, end, rest)`` -- ISO date strings (or None) and the remaining value,
which is what :func:`parse_scheduled_message_value` expects.
Raises:
ValueError: If a bound is repeated, is not an ISO date, or ends after it starts.
"""
rest = raw or ""
found: dict[str, str] = {}
while True:
match = _BOUND_RE.match(rest)
if not match:
break
key = match.group("key").lower()
if key in found:
raise ValueError(f"{key}= given more than once")
try:
datetime.date.fromisoformat(match.group("date"))
except ValueError:
raise ValueError(
f"{key}={match.group('date')} is not an ISO date (YYYY-MM-DD)"
) from None
found[key] = match.group("date")
rest = rest[match.end():]
start, end = found.get("start"), found.get("end")
if start and end and end < start:
raise ValueError(f"end={end} is before start={start}")
return start, end, rest.strip()
def parse_scheduled_message_value(raw: str) -> tuple[str, str, str | None]:
"""Parse a ``[Scheduled_Messages]`` option value into ``(channel, message, scope)``.
@@ -43,6 +93,9 @@ def parse_scheduled_message_value(raw: str) -> tuple[str, str, str | None]:
s = (raw or "").strip()
if ":" not in s:
raise ValueError("scheduled message value must be channel:message")
if _BOUND_RE.match(s):
# split_schedule_bounds() removes these; reaching here means a caller skipped it.
raise ValueError("start=/end= bounds must be stripped before parsing the value")
parts = s.split(":", 2)
if len(parts) == 3 and parts[1].strip().startswith("#"):
channel = parts[0].strip()
@@ -78,6 +131,50 @@ class ScheduleParseResult:
"""True when the legacy HHMM daily form was used."""
# APScheduler's day-of-month field already understands positional expressions such as
# "last fri" and "4th tue", but they contain a space, which is crontab's field separator
# -- so from_crontab() can never reach them. Accept "-" or "_" in place of that space and
# restore it before handing the field over. Neither separator is ambiguous: crontab range
# endpoints are digits, so "last-fri" cannot be read as a range.
_POSITIONAL_DOM_RE = re.compile(
r"(1st|2nd|3rd|4th|5th|last)[-_](mon|tue|wed|thu|fri|sat|sun)",
re.IGNORECASE,
)
def _from_crontab(expr: str, timezone, start_date=None, end_date=None) -> CronTrigger:
"""``CronTrigger.from_crontab`` plus escaped positional day-of-month and date bounds.
``0 19 last-fri * *`` fires 19:00 on the last Friday of each month, and
``0 19 1st-tue,3rd-tue * *`` on the first and third Tuesday. Field splitting and
the resulting trigger are otherwise identical to ``CronTrigger.from_crontab``,
which is itself only this constructor call -- reproduced here because it takes no
``start_date``/``end_date``.
Raises:
ValueError: If ``expr`` is not a valid 5-field crontab expression.
"""
fields = expr.split()
if len(fields) != 5:
raise ValueError(f"Wrong number of fields; got {len(fields)}, expected 5")
# Only the day-of-month field takes positional expressions; an escape anywhere else
# is left in place so APScheduler rejects it.
fields[2] = _POSITIONAL_DOM_RE.sub(r"\1 \2", fields[2])
return CronTrigger(
minute=fields[0],
hour=fields[1],
day=fields[2],
month=fields[3],
day_of_week=fields[4],
timezone=timezone,
start_date=start_date,
# An end date reads as "through this day", so run it to the end of that day
# rather than stopping at its midnight.
end_date=f"{end_date} 23:59:59" if end_date else None,
)
def is_valid_legacy_hhmm(time_str: str) -> bool:
"""Return True if ``time_str`` is a valid legacy HHMM clock time (24h)."""
try:
@@ -93,12 +190,18 @@ def is_valid_legacy_hhmm(time_str: str) -> bool:
def parse_schedule_key(
schedule_key: str,
timezone,
start_date: str | None = None,
end_date: str | None = None,
) -> ScheduleParseResult:
"""Parse a ``[Scheduled_Messages]`` option name into a :class:`CronTrigger`.
Args:
schedule_key: Raw config option key (e.g. ``0 9 * * *``, ``@daily``, ``0900``).
schedule_key: Raw config option key (e.g. ``0 9 * * *``, ``0 19 last-fri * *``,
``@daily``, ``0900``).
timezone: ``tzinfo`` or string accepted by APScheduler (same as scheduler).
start_date: Optional ISO date; the schedule does not fire before it.
end_date: Optional ISO date; the schedule does not fire after the end of it.
Both come from the option *value* via :func:`split_schedule_bounds`.
Returns:
ScheduleParseResult with ``trigger`` set when valid, else ``trigger`` is None
@@ -114,7 +217,9 @@ def parse_schedule_key(
if is_valid_legacy_hhmm(raw):
hour = int(raw[:2])
minute = int(raw[2:])
trigger = CronTrigger(hour=hour, minute=minute, timezone=timezone)
trigger = _from_crontab(
f"{minute} {hour} * * *", timezone, start_date, end_date
)
display = f"{hour:02d}:{minute:02d}"
return ScheduleParseResult(trigger, display, True)
@@ -122,14 +227,14 @@ def parse_schedule_key(
if lowered in _SPECIAL_PRESET_TO_CRON:
cron_expr = _SPECIAL_PRESET_TO_CRON[lowered]
try:
trigger = CronTrigger.from_crontab(cron_expr, timezone=timezone)
trigger = _from_crontab(cron_expr, timezone, start_date, end_date)
except ValueError:
return ScheduleParseResult(None, raw, False)
return ScheduleParseResult(trigger, raw, False)
# 3) Standard 5-field crontab
# 3) Standard 5-field crontab (optionally with a positional day-of-month)
try:
trigger = CronTrigger.from_crontab(raw, timezone=timezone)
trigger = _from_crontab(raw, timezone, start_date, end_date)
except ValueError:
return ScheduleParseResult(None, raw, False)
return ScheduleParseResult(trigger, raw, False)
+19 -2
View File
@@ -28,6 +28,7 @@ from .scheduled_message_cron import (
is_valid_legacy_hhmm,
parse_schedule_key,
parse_scheduled_message_value,
split_schedule_bounds,
)
from .security_utils import validate_external_url
from .utils import (
@@ -116,13 +117,29 @@ class MessageScheduler:
for schedule_key, message_info in self.bot.config.items('Scheduled_Messages'):
self.logger.info(f"Processing scheduled message: '{schedule_key}' -> '{message_info}'")
try:
parsed = parse_schedule_key(schedule_key, tz)
# Optional start=/end= bounds ride on the value, since crontab has
# no field for them; strip them before reading channel:message.
start_date, end_date, value = split_schedule_bounds(message_info)
parsed = parse_schedule_key(schedule_key, tz, start_date, end_date)
if parsed.trigger is None:
self.logger.warning(
f"Invalid schedule '{schedule_key}' for scheduled message: {message_info}"
)
continue
if (start_date or end_date) and parsed.trigger.get_next_fire_time(
None, datetime.datetime.now(tz)
) is None:
self.logger.warning(
"Scheduled_Messages key %r is bounded to %s..%s and has no "
"runs left; not scheduled: %s",
schedule_key,
start_date or "any date",
end_date or "any date",
message_info,
)
continue
if parsed.is_deprecated_hhmm:
hh = int(schedule_key[:2])
mm = int(schedule_key[2:])
@@ -135,7 +152,7 @@ class MessageScheduler:
cron_suggestion,
)
channel, message, scope = parse_scheduled_message_value(message_info)
channel, message, scope = parse_scheduled_message_value(value)
message = decode_escape_sequences(message)
if self._has_command_placeholders(message):
+14 -3
View File
@@ -4025,6 +4025,8 @@ class BotDataViewer:
_schedule_tz(),
message=data.get('message', ''),
count=count,
start=(data.get('start') or '').strip() or None,
end=(data.get('end') or '').strip() or None,
))
except Exception as e:
self.logger.error(f"Error previewing schedule: {e}")
@@ -4040,12 +4042,17 @@ class BotDataViewer:
channel = (data.get('channel') or '').strip()
message = (data.get('message') or '').strip()
scope = (data.get('scope') or '').strip() or None
# Optional date bounds; they live on the value, not the schedule key.
start = (data.get('start') or '').strip() or None
end = (data.get('end') or '').strip() or None
field_error = validate_entry(channel, message, scope)
field_error = validate_entry(channel, message, scope, start, end)
if field_error:
return jsonify({'success': False, 'error': field_error}), 400
described = describe_schedule(schedule, _schedule_tz(), message=message)
described = describe_schedule(
schedule, _schedule_tz(), message=message, start=start, end=end
)
if not described.get('valid'):
return jsonify({'success': False, 'error': described.get('error')}), 400
@@ -4070,7 +4077,11 @@ class BotDataViewer:
),
}), 409
updates = {SCHEDULED_MESSAGES_SECTION: {schedule: compose_value(channel, message, scope)}}
updates = {
SCHEDULED_MESSAGES_SECTION: {
schedule: compose_value(channel, message, scope, start, end)
}
}
deletes = None
if replacing and replacing != schedule:
deletes = {SCHEDULED_MESSAGES_SECTION: [replacing]}
+36 -3
View File
@@ -101,6 +101,8 @@
Five fields: minute hour day-of-month month day-of-week.
Day-of-week is 0=Monday here, not Sunday. Presets like
<code>@daily</code> and <code>@hourly</code> also work.
Day-of-month also takes <code>last-fri</code>,
<code>4th-tue</code> or <code>1st-tue,3rd-tue</code>.
</div>
</div>
</div>
@@ -116,6 +118,21 @@
<div class="form-text">Leave blank for global flood.</div>
</div>
<div class="row g-2 mb-3">
<div class="col-sm-6">
<label class="form-label" for="scheduleStart">Start date <span class="text-muted">(optional)</span></label>
<input type="date" class="form-control" id="scheduleStart">
</div>
<div class="col-sm-6">
<label class="form-label" for="scheduleEnd">End date <span class="text-muted">(optional)</span></label>
<input type="date" class="form-control" id="scheduleEnd">
</div>
<div class="form-text">
Limit this schedule to a date range. The end date is included in full.
Leave both blank to run indefinitely.
</div>
</div>
<div class="mb-3">
<label class="form-label" for="scheduleMessage">Message</label>
<textarea class="form-control" id="scheduleMessage" rows="3"
@@ -265,7 +282,13 @@
try {
const data = await api('/api/scheduled-messages/preview', {
method: 'POST',
body: JSON.stringify({ schedule: cron, message: $('scheduleMessage').value, count: 5 })
body: JSON.stringify({
schedule: cron,
message: $('scheduleMessage').value,
start: $('scheduleStart').value,
end: $('scheduleEnd').value,
count: 5
})
});
if (data.valid) {
$('previewRuns').innerHTML = (data.next_runs || [])
@@ -287,6 +310,8 @@
$('scheduleChannel').value = entry ? entry.channel : '';
$('scheduleScope').value = entry && entry.scope ? entry.scope : '';
$('scheduleMessage').value = entry ? entry.message : '';
$('scheduleStart').value = entry && entry.start ? entry.start : '';
$('scheduleEnd').value = entry && entry.end ? entry.end : '';
// Existing entries open in Advanced so the stored cron is never silently
// rewritten into something the friendly controls happen to produce.
$('scheduleMode').value = entry ? 'advanced' : 'daily';
@@ -301,7 +326,9 @@
schedule: cron,
channel: $('scheduleChannel').value,
scope: $('scheduleScope').value,
message: $('scheduleMessage').value
message: $('scheduleMessage').value,
start: $('scheduleStart').value,
end: $('scheduleEnd').value
};
const editing = editingSchedule !== null;
if (editing) payload.original_schedule = editingSchedule;
@@ -355,9 +382,15 @@
let scheduleCell = '<span class="font-monospace">' + escapeHtml(entry.schedule) + '</span>';
if (entry.error) {
scheduleCell += '<br><span class="badge bg-danger">Not scheduled</span>';
} else if (entry.finished) {
scheduleCell += '<br><span class="badge bg-secondary">Finished</span>';
} else if (entry.deprecated) {
scheduleCell += '<br><span class="badge bg-warning text-dark">Deprecated format</span>';
}
if (entry.start || entry.end) {
scheduleCell += '<br><span class="small text-muted">'
+ escapeHtml((entry.start || '…') + ' → ' + (entry.end || '…')) + '</span>';
}
const scopeBadge = entry.scope
? ' <span class="badge bg-secondary">' + escapeHtml(entry.scope) + '</span>' : '';
@@ -403,7 +436,7 @@
$('saveScheduleBtn').addEventListener('click', save);
$('scheduleMode').addEventListener('change', syncModeFields);
['scheduleTime', 'scheduleTimes', 'scheduleEveryHours', 'scheduleEveryMinutes',
'scheduleCron', 'scheduleMessage'].forEach((id) =>
'scheduleCron', 'scheduleMessage', 'scheduleStart', 'scheduleEnd'].forEach((id) =>
$(id).addEventListener('input', schedulePreview));
syncModeFields();
load();
+150 -1
View File
@@ -8,7 +8,11 @@ from zoneinfo import ZoneInfo
import pytest
from modules.scheduled_message_cron import parse_schedule_key
from modules.scheduled_message_cron import (
parse_schedule_key,
parse_scheduled_message_value,
split_schedule_bounds,
)
from modules.scheduler import MessageScheduler
@@ -52,6 +56,151 @@ class TestParseScheduleKey:
r = parse_schedule_key("not-a-valid-cron", tz)
assert r.trigger is None
@pytest.mark.parametrize("expr", [
# Forms plain crontab already accepts; guards against a parser that narrows them.
"0 8 * * *", "*/15 * * * *", "0 6,12,18 * * *", "0 8 * * mon-fri",
"0 0-20/2 * * *", "0-10,30 8 * * *", "0 0 1-15/3 * *", "0 19 last * *",
"0 8 15 jan *", "0 8 * may-oct,dec *",
])
def test_standard_crontab_forms_still_parse(self, expr):
assert parse_schedule_key(expr, ZoneInfo("UTC")).trigger is not None
class TestPositionalDayOfMonth:
"""Escaped positional day-of-month expressions (last-fri, 4th-tue, 1st-mon,3rd-mon).
APScheduler understands these natively; the escape only works around the space that
would otherwise be read as a crontab field separator.
"""
TZ = ZoneInfo("UTC")
NOW = datetime.datetime(2026, 8, 29, 12, 0, tzinfo=ZoneInfo("UTC"))
def _next_runs(self, expr, count=3):
trigger = parse_schedule_key(expr, self.TZ).trigger
assert trigger is not None, f"{expr!r} did not parse"
runs, prev, now = [], None, self.NOW
for _ in range(count):
fire = trigger.get_next_fire_time(prev, now)
runs.append(fire)
prev, now = fire, fire + datetime.timedelta(seconds=1)
return runs
def test_last_weekday_of_month(self):
assert self._next_runs("0 19 last-fri * *") == [
datetime.datetime(2026, 9, 25, 19, 0, tzinfo=self.TZ),
datetime.datetime(2026, 10, 30, 19, 0, tzinfo=self.TZ),
datetime.datetime(2026, 11, 27, 19, 0, tzinfo=self.TZ),
]
def test_nth_weekday_of_month(self):
assert self._next_runs("0 19 4th-tue * *") == [
datetime.datetime(2026, 9, 22, 19, 0, tzinfo=self.TZ),
datetime.datetime(2026, 10, 27, 19, 0, tzinfo=self.TZ),
datetime.datetime(2026, 11, 24, 19, 0, tzinfo=self.TZ),
]
def test_list_of_positions_gives_a_fortnightly_net(self):
# "1st and 3rd Tuesday" -- what recurring nets usually mean by "every other".
assert self._next_runs("0 19 1st-tue,3rd-tue * *") == [
datetime.datetime(2026, 9, 1, 19, 0, tzinfo=self.TZ),
datetime.datetime(2026, 9, 15, 19, 0, tzinfo=self.TZ),
datetime.datetime(2026, 10, 6, 19, 0, tzinfo=self.TZ),
]
def test_underscore_is_accepted_as_the_separator(self):
assert self._next_runs("0 19 last_fri * *", 1) == self._next_runs("0 19 last-fri * *", 1)
def test_is_case_insensitive(self):
assert self._next_runs("0 19 LAST-FRI * *", 1) == self._next_runs("0 19 last-fri * *", 1)
def test_combines_with_a_month_restriction(self):
assert self._next_runs("0 19 last-fri jan-jun *", 1) == [
datetime.datetime(2027, 1, 29, 19, 0, tzinfo=self.TZ),
]
@pytest.mark.parametrize("expr", [
"0 19 * * last-fri", # positional expressions are day-of-month only
"0 19 last-fri *", # too few fields
"0 19 last-fri * * *", # too many fields
"last-fri", # not a crontab expression
"0 19 6th-tue * *", # there is no 6th weekday
])
def test_rejects_misplaced_or_malformed_expressions(self, expr):
assert parse_schedule_key(expr, self.TZ).trigger is None
class TestScheduleBounds:
"""start=/end= date bounds, which ride on the option value (crontab has no field)."""
TZ = ZoneInfo("UTC")
@pytest.mark.parametrize("raw, expected", [
("Public:Hello", (None, None, "Public:Hello")),
("start=2027-01-01 Public:Hi", ("2027-01-01", None, "Public:Hi")),
("end=2027-03-31 Public:Hi", (None, "2027-03-31", "Public:Hi")),
("start=2027-01-01 end=2027-03-31 Public:#sea:Hi",
("2027-01-01", "2027-03-31", "Public:#sea:Hi")),
# order does not matter, and the keyword is case-insensitive
("end=2027-03-31 start=2027-01-01 Public:Hi",
("2027-01-01", "2027-03-31", "Public:Hi")),
("START=2027-01-01 Public:Hi", ("2027-01-01", None, "Public:Hi")),
])
def test_splits_bounds_off_the_front(self, raw, expected):
assert split_schedule_bounds(raw) == expected
def test_a_message_body_is_never_mistaken_for_a_bound(self):
# Bounds are anchored to the front, so free text stays untouched.
raw = "Public:Sign-ups start=2027 and end=soon"
assert split_schedule_bounds(raw) == (None, None, raw)
@pytest.mark.parametrize("bad", [
"start=2027-13-45 Public:Hi", # not a real date
"start=tomorrow Public:Hi", # not ISO
"start=2027-01-01 start=2027-02-01 P:Hi", # repeated
"start=2027-03-01 end=2027-01-01 P:Hi", # ends before it starts
])
def test_rejects_malformed_bounds(self, bad):
with pytest.raises(ValueError):
split_schedule_bounds(bad)
def test_value_parser_refuses_unstripped_bounds(self):
# A caller that forgets split_schedule_bounds() must fail loudly rather than
# silently treat "start=2027-01-01 Public" as a channel name.
with pytest.raises(ValueError):
parse_scheduled_message_value("start=2027-01-01 Public:Hi")
def _first_runs(self, key, start=None, end=None, count=3):
trigger = parse_schedule_key(key, self.TZ, start, end).trigger
assert trigger is not None
runs, prev, now = [], None, datetime.datetime(2026, 8, 29, 12, 0, tzinfo=self.TZ)
for _ in range(count):
fire = trigger.get_next_fire_time(prev, now)
if fire is None:
break
runs.append(fire)
prev, now = fire, fire + datetime.timedelta(seconds=1)
return runs
def test_start_date_defers_the_first_run(self):
assert self._first_runs("0 19 * * *", start="2027-03-01", count=1) == [
datetime.datetime(2027, 3, 1, 19, 0, tzinfo=self.TZ)
]
def test_end_date_includes_the_whole_final_day(self):
# "end=2026-09-02" means through the 2nd, not up to its midnight.
assert self._first_runs("0 19 * * *", end="2026-09-02", count=9)[-1] == (
datetime.datetime(2026, 9, 2, 19, 0, tzinfo=self.TZ)
)
def test_bounds_apply_to_positional_preset_and_legacy_forms(self):
for key in ("0 19 last-fri * *", "@daily", "0900"):
runs = self._first_runs(key, start="2027-03-01", count=1)
assert runs and runs[0] >= datetime.datetime(2027, 3, 1, tzinfo=self.TZ), key
def test_an_exhausted_schedule_has_no_runs(self):
assert self._first_runs("0 19 * * *", end="2020-01-01") == []
class TestIsValidTimeFormat:
"""Tests for _is_valid_time_format()."""
+63 -2
View File
@@ -22,11 +22,14 @@ TZ = datetime.timezone.utc
@pytest.mark.unit
class TestDescribeSchedule:
@pytest.mark.parametrize("cron", ["0 8 * * *", "0 6,12,18 * * *", "*/30 * * * *", "@daily"])
@pytest.mark.parametrize("cron", ["0 8 * * *", "0 6,12,18 * * *", "*/30 * * * *", "@daily",
"0 19 last-fri * *", "0 19 4th-tue * *",
"0 19 1st-tue,3rd-tue * *", "0 19 last_fri * *"])
def test_accepts_valid_schedules(self, cron):
assert describe_schedule(cron, TZ)["valid"] is True
@pytest.mark.parametrize("bad", ["", " ", "nonsense", "0 8 * *", "99 99 * * *"])
@pytest.mark.parametrize("bad", ["", " ", "nonsense", "0 8 * *", "99 99 * * *",
"0 19 * * last-fri", "0 19 6th-tue * *"])
def test_rejects_invalid_schedules(self, bad):
result = describe_schedule(bad, TZ)
assert result["valid"] is False
@@ -142,3 +145,61 @@ class TestReadEntries:
def test_missing_file_is_empty_not_an_error(self, tmp_path):
assert read_entries(str(tmp_path / "nope.ini"), TZ) == []
@pytest.mark.unit
class TestDateBounds:
"""start=/end= bounds round-trip through the value, so a UI edit cannot drop them."""
@pytest.mark.parametrize("channel, message, scope, start, end, expected", [
("Public", "Hi", None, None, None, "Public:Hi"),
("Public", "Hi", None, "2027-01-01", None, "start=2027-01-01 Public:Hi"),
("Public", "Hi", None, None, "2027-03-31", "end=2027-03-31 Public:Hi"),
("Public", "Hi", "#sea", "2027-01-01", "2027-03-31",
"start=2027-01-01 end=2027-03-31 Public:#sea:Hi"),
])
def test_compose_value_places_bounds_ahead_of_the_channel(
self, channel, message, scope, start, end, expected
):
assert compose_value(channel, message, scope, start, end) == expected
@pytest.mark.parametrize("raw", [
"Public:Hi",
"start=2027-01-01 Public:Hi",
"end=2027-03-31 Public:#sea:Hi",
"start=2027-01-01 end=2027-03-31 Public:#sea:Hi",
])
def test_read_then_recompose_is_lossless(self, tmp_path, raw):
# The web UI edit cycle: read an entry, hand it back to compose_value unchanged.
config = tmp_path / "config.ini"
config.write_text(f"[Scheduled_Messages]\n0 19 * * * = {raw}\n", encoding="utf-8")
entry = read_entries(str(config), TZ)[0]
assert compose_value(
entry["channel"], entry["message"], entry["scope"],
entry["start"], entry["end"],
) == raw
def test_bounded_schedule_previews_within_its_window(self):
result = describe_schedule("0 19 * * *", TZ, start="2027-03-01", end="2027-03-03")
assert result["valid"] is True
assert [r[:10] for r in result["next_runs"]] == [
"2027-03-01", "2027-03-02", "2027-03-03",
]
def test_exhausted_schedule_is_flagged_finished_not_invalid(self):
result = describe_schedule("0 19 * * *", TZ, end="2020-01-01")
assert result["valid"] is True # well-formed, just out of runs
assert result["finished"] is True
assert result["next_runs"] == []
assert "no runs left" in result["warning"]
@pytest.mark.parametrize("start, end, expected", [
("2027-13-45", None, "Start date must be an ISO date (YYYY-MM-DD)"),
(None, "nope", "End date must be an ISO date (YYYY-MM-DD)"),
("2027-03-01", "2027-01-01", "End date is before the start date"),
("2027-01-01", "2027-03-01", None),
(None, None, None),
])
def test_validate_entry_checks_the_dates(self, start, end, expected):
assert validate_entry("Public", "Hi", None, start, end) == expected