From abd39acfa37b9f39313b05d649cdcf4da933928b Mon Sep 17 00:00:00 2001 From: Adam Gessaman Date: Sat, 29 Aug 2026 13:34:05 -0700 Subject: [PATCH] feat(scheduler): add positional day-of-month and date bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 21 +++ config.ini.example | 20 +++ docs/configuration.md | 41 ++++++ modules/scheduled_message_admin.py | 89 ++++++++++-- modules/scheduled_message_cron.py | 115 +++++++++++++++- modules/scheduler.py | 21 ++- modules/web_viewer/app.py | 17 ++- modules/web_viewer/templates/schedule.html | 39 +++++- tests/test_scheduler_logic.py | 151 ++++++++++++++++++++- tests/unit/test_scheduled_message_admin.py | 65 ++++++++- 10 files changed, 550 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73f6da7..f60e659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/config.ini.example b/config.ini.example index 95c759d..f0c8a54 100644 --- a/config.ini.example +++ b/config.ini.example @@ -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=". +# = 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 +# # 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 mon–sun. 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 diff --git a/docs/configuration.md b/docs/configuration.md index db120f2..aeedfa4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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** Vixie’s `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. diff --git a/modules/scheduled_message_admin.py b/modules/scheduled_message_admin.py index 5ed5b76..08d754c 100644 --- a/modules/scheduled_message_admin.py +++ b/modules/scheduled_message_admin.py @@ -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 diff --git a/modules/scheduled_message_cron.py b/modules/scheduled_message_cron.py index 2ce188b..653ea7d 100644 --- a/modules/scheduled_message_cron.py +++ b/modules/scheduled_message_cron.py @@ -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*(?Pstart|end)=(?P\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) diff --git a/modules/scheduler.py b/modules/scheduler.py index cd91b6b..79611f2 100644 --- a/modules/scheduler.py +++ b/modules/scheduler.py @@ -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): diff --git a/modules/web_viewer/app.py b/modules/web_viewer/app.py index 3c1352c..dd1160c 100644 --- a/modules/web_viewer/app.py +++ b/modules/web_viewer/app.py @@ -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]} diff --git a/modules/web_viewer/templates/schedule.html b/modules/web_viewer/templates/schedule.html index de22d5b..5ae1283 100644 --- a/modules/web_viewer/templates/schedule.html +++ b/modules/web_viewer/templates/schedule.html @@ -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 @daily and @hourly also work. + Day-of-month also takes last-fri, + 4th-tue or 1st-tue,3rd-tue. @@ -116,6 +118,21 @@
Leave blank for global flood.
+
+
+ + +
+
+ + +
+
+ Limit this schedule to a date range. The end date is included in full. + Leave both blank to run indefinitely. +
+
+