Conversation
Redeploys were previously silent about which build was actually running. Add a startup banner that logs the package version and the resolved settings (bot username, timezone, database path, schema version, poll timeout, scheduler interval, draft/confirming TTLs, log level) so operators can confirm at a glance which build is live. Token is intentionally excluded and covered by a test to prevent accidental future leaks. Generated with AI Co-Authored-By: AI <ai@example.com>
feat: log version and key settings on startup
Phase 1 of recurring reminders: persist a recurrence rule per reminder, compute the next fire deterministically, and re-schedule recurring reminders after each delivery instead of marking them FIRED. No natural language parsing yet (that is phase 2) and no UX changes (phase 3). - Add RecurrenceRule dataclass with RecurrencePeriod enum covering daily / weekly / monthly / yearly with hour + minute and the period-specific selectors (weekdays / month_days / year_month + year_day). - Add next_fire(rule, after) pure function. Strictly returns times after `after` so a tick landing on the exact target does not reschedule to the same second. Monthly scans up to 3 months ahead (handles 31 in short months), yearly up to 5 years ahead (handles Feb 29 in non-leap years). - Add serialize_rule / deserialize_rule so the DB stores only the fields actually used by each period. - Schema v4: add nullable `recurrence` text column on reminders. Existing rows are unaffected (one-time reminders keep recurrence = NULL and behave exactly as before). - Repository: create_reminder persists recurrence; add reschedule (FIRING → PENDING with new remind_at) guarded on FIRING to avoid double-processing; row_to_reminder deserialises when present. - Scheduler tick: if reminder.recurrence is set, send + reschedule to next_fire; otherwise unchanged mark_fired path. Tests cover: serialize/deserialize roundtrip for all four periods; next_fire behaviour for each period including the day-31-in-Feb skip, leap year skip, and same-second-rollover; persistence roundtrip; reschedule status guard; scheduler integration that verifies FIRED is not called for recurring reminders. Generated with AI Co-Authored-By: AI <ai@example.com>
next_fire uses after.tzinfo to construct the target HH:MM. Passing scheduler-timezone `now` therefore anchored the "every day 09:00" rule to the scheduler host tz, so a UTC-hosted bot serving an Asia/Taipei user would fire at 09:00 UTC (17:00 Taipei) instead of 09:00 Taipei. Convert now to reminder.timezone before calling next_fire; keep `now` for reschedule() metadata unchanged. Regression test asserts a daily 09:00 Asia/Taipei rule ticked from a UTC scheduler reschedules to next-day 09:00 in Asia/Taipei (not 09:00 UTC). Generated with AI Co-Authored-By: AI <ai@example.com>
Lexicographic ISO string comparison in claim_due / list_pending mishandles reminders whose remind_at is stored with a different tz offset than the scheduler's now — the same instant may sort as "greater" or "less" depending on the two offsets. Concretely, a bot hosted in UTC serving Asia/Taipei users would never claim their reminders because e.g. "2026-07-03T09:00:00+08:00" is not lexicographically <= "2026-07-03T01:00:00+00:00" even though they are the same moment. Wrap remind_at comparisons and ordering in julianday(), which parses ISO 8601 (including the timezone offset and optional microseconds) into a Julian Day number so comparison reflects the absolute instant regardless of how it is written. Regression tests add a cross-tz claim_due hit and a cross-tz just-before-due miss. Also fix a comment/behavior mismatch in next_fire yearly branch: the comment claimed the loop only scanned "this year and next year", but the actual range was 5. Bump range to 9 to cover the century leap year gap (2096 leap → 2100 non-leap → 2104 leap = 8 years) and update the comment. Regression test locks the 2097 → 2104 case. Generated with AI Co-Authored-By: AI <ai@example.com>
feat(recurrence): phase 1 — data model, storage, scheduler
Phase 2 of recurring reminders: recognise common Chinese recurrence phrases at parse time, produce a RecurrenceRule alongside the first-fire remind_at, and thread it through draft → confirm → persisted Reminder so Phase 1's scheduler picks it up unchanged. Supported markers (all require an explicit HH:MM in the same message; missing time falls through to the one-time parser): - 每天 / 每日 09:00 ... → DAILY - 每週一 / 每禮拜一 / 每星期一 09:00 ... → WEEKLY single day - 每週一三五 / 每週一、三、五 / 每週一,三,五 09:00 ... → WEEKLY multi - 每個月 1 號 / 每月 1 號 09:00 ... → MONTHLY single day - 每個月 1, 18, 25 號 / 每個月 1、18、25 號 09:00 ... → MONTHLY multi - 每年 12月25號 / 每年 12/25 08:00 ... → YEARLY Parser calls next_fire(rule, now) to seed the first remind_at, so the scheduler's existing recurrence branch (Phase 1) picks it up on the very first tick after creation without any extra plumbing. Wiring: - ParseResult and ReminderDraft gain optional recurrence fields. - Service.begin_create copies parse_result.recurrence onto the draft; confirm() copies draft.recurrence onto the created Reminder. - Schema v5: reminder_drafts.recurrence text column (nullable). Reuses the existing recurrence.serialize/deserialize helpers for consistency with the reminders table. Out of scope (phase 3): confirmation card wording, /list display, edit flow for recurring rules, skip-once button. This PR keeps the UX identical — the confirmation card still shows a single remind_at (first fire), but under the hood the reminder repeats. Tests: - 12 parser cases covering all 4 periods and all separator variants, including the user's original example "每個月 1, 18, 25 號 09:00". - Draft persistence roundtrip for recurrence. - Service end-to-end: begin_create populates draft.recurrence and confirm transfers it to the stored Reminder. - Non-recurring regression case ensuring existing parser paths are not disturbed. Generated with AI Co-Authored-By: AI <ai@example.com>
Monthly regex allowed 0..99 for day-of-month, and yearly regex allowed any 1..2-digit month and day. Both patterns then fed the raw values straight into next_fire, which called datetime(y, m, d, ...) with unchecked inputs. A message like "每個月 0 號 09:00 …" or "每年 13/40 08:00 …" would raise ValueError from the datetime constructor (monthly) or exhaust the 9-year scan and raise RuntimeError (yearly), and both errors bubbled out of parse() — the bot would fail to respond to that update. - Filter monthly month_days to 1..31 in `_detect_recurrence_marker`. If everything is filtered out, fall through to the one-off parser instead of building an empty rule. Partial-valid input is kept (e.g. "15, 45 號" keeps 15 and drops 45). - For yearly, validate the (month, day) pair by attempting datetime(2028, month, day). 2028 is a leap year so 2/29 is accepted as a valid rule (yearly next_fire already handles the leap-year skip). Combinations that never exist (2/30, 4/31, 13/1, ...) are rejected and fall through. - Also add the same "empty tuple" guard on weekly so a marker with no recognisable weekday chars falls through cleanly. Regression tests cover the zero/out-of-range month days, partial valid days, invalid month, impossible date, and 2/29 acceptance. Generated with AI Co-Authored-By: AI <ai@example.com>
The recurrence path never used the zone parameter — timezone information is carried by `now.tzinfo`, and `next_fire` derives its own zone from `after.tzinfo`. Keeping a declared-but-unused zone argument is misleading for anyone reading or modifying the code later (they might think swapping it changes behaviour). Remove it from the signature and update the sole call site. Generated with AI Co-Authored-By: AI <ai@example.com>
The recurrence path had its own leading-connector strip pattern that included 要 (`^\s*(?:和|跟|與|要|在)\s*`), while the one-off split path deliberately keeps 要 (so "1號要去家樂福" stays intact). That inconsistency broke the "UX unchanged" claim: a phrase like "每天 09:00 提醒我要運動" would produce title "運動" via the recurrence path but "要運動" via the one-off path for equivalent sentence structure. Strip only what is specific to recurrence — the marker, the time, and 提醒(我/我們/大家)? — then delegate to the shared _clean_content_title for participant / mention / connector cleanup. Both paths now produce identical titles for the same tail phrasing. Regression test asserts "每天 09:00 提醒我要運動" → title="要運動". Generated with AI Co-Authored-By: AI <ai@example.com>
feat(recurrence): phase 2 — natural language parser for recurrence
Phase 3 of recurring reminders. Backend was fully wired in phases 1 and 2, but nothing on the user-facing side reflected that a reminder was recurring: the confirmation card, the created message, /list, the detail view, and the delivery notification all rendered the first fire time as if it were a one-off. Add `format_rule(rule)` in recurrence.py that turns a RecurrenceRule into a Chinese-readable string: DAILY 09:00 → 每天 09:00 WEEKLY weekdays=(0,) 09:00 → 每週一 09:00 WEEKLY weekdays=(0,2,4) 09:00 → 每週一、三、五 09:00 MONTHLY month_days=(15,) 09:00 → 每月 15 號 09:00 MONTHLY month_days=(1,18,25) 09:00 → 每月 1, 18, 25 號 09:00 YEARLY 12/25 08:00 → 每年 12/25 08:00 Use it in the renderer to distinguish recurring from one-off: - render_confirmation / render_created / render_detail: replace the "時間:..." line with two lines "重複:<rule>" and "下次:<first fire>", so users can see the pattern and confirm the first time before committing. - render_delivery: append "重複:<rule>" to the delivery notification so users know they will get it again (not compute-next to keep the renderer decoupled from scheduler timing). - render_grouped_list and reminder_list_keyboard: prefix recurring reminders with "[重複]" for at-a-glance scanning. Out of scope (phase 4, if wanted): - Editing the recurrence rule (currently edit only covers title/time). - "跳過本次" button on delivery. - "取消整個系列" on delivery. Generated with AI Co-Authored-By: AI <ai@example.com>
`format_rule` previously mirrored the happy-path assumption baked into `next_fire` without matching its validation. That let it emit malformed strings for invalid rules and even crash on out-of-range weekdays: - WEEKLY with empty `weekdays` rendered as "每週 09:00". - YEARLY with missing `year_month`/`year_day` rendered as "每年 None/None 09:00". - WEEKLY with a weekday outside 0..6 raised `IndexError` from `_CHINESE_WEEKDAYS[d]` instead of a caller-actionable `ValueError`. - Weekdays / month_days were joined in declaration order; a rule built manually without `sorted()` would surface unsorted labels (parser always sorts, but the model type does not enforce it). Align `format_rule` validation with `next_fire`: raise `ValueError` for empty weekly weekdays, empty monthly month_days, missing yearly fields, and any out-of-range value; sort weekdays / month_days defensively before joining so the user-facing string is stable regardless of how the rule was constructed. Also add a `reminder_list_keyboard` regression test asserting the `[重複]` prefix appears on recurring buttons and not on one-off buttons, symmetric with the existing `render_grouped_list` test. Generated with AI Co-Authored-By: AI <ai@example.com>
format_rule now raises for empty weekly/monthly values and out-of-range weekdays/month days, but yearly month/day were still accepted as-is. A rule with year_month=13 or year_day=40 would silently render "每年 13/40 08:00", contradicting the "raise on malformed" contract added in the previous commit. Parser already had a `_is_valid_month_day` helper (probing with a leap year so 2/29 is accepted, 2/30 / 4/31 / 13/1 rejected). Promote it to a public `is_valid_month_day` in recurrence.py so parser and format_rule share one source of truth, and use it in the yearly format_rule branch. This avoids validation drift between the two callers. Regression tests cover out-of-range yearly month (13/25), impossible yearly date (4/31), and confirmed acceptance of the leap-day case (2/29 renders as "每年 2/29 08:00"). Generated with AI Co-Authored-By: AI <ai@example.com>
The previous docstring claimed format_rule's validation "aligned with next_fire", but that is not quite right: format_rule now additionally rejects out-of-range weekday/month_day values and invalid yearly dates up front, whereas next_fire only enforces required-field presence and would otherwise scan-until-not-found. Rephrase the docstring to make the actual contract explicit — same required-field rules as next_fire, plus stricter value-range and date-existence checks — and explain why the two callers differ: format_rule produces user-visible strings so must reject malformed rules immediately, while next_fire runs on scheduler ticks where finding a valid future occurrence is the only concern. This avoids misleading future maintainers who might assume the two functions validate identically. No behaviour change; docstring only. Generated with AI Co-Authored-By: AI <ai@example.com>
format_rule already rejects out-of-range weekdays, month_days, and yearly dates, but hour and minute were passed through unchecked. A corrupted DB payload (or a manually constructed rule) with hour=99 would render "99:99" instead of failing fast — inconsistent with the "reject malformed rule" contract stated in the docstring. Add hour ∈ 0..23 and minute ∈ 0..59 checks before formatting. Kept inside format_rule for now (matching the other range checks); the longer-term cleanup would be to move all validation into RecurrenceRule.__post_init__ so no invalid rule can exist, but that touches serialisation / persistence and is deferred. Generated with AI Co-Authored-By: AI <ai@example.com>
Line 49 unconditionally called re.escape(bot_username) before the None guard on line 50, so command_body raised TypeError whenever BOT_USERNAME was unset. That propagated through command_handlers. handle → router.handle_update → app.py's outer try/except, which silently logged the error, and users saw no response to any slash command. Bots deployed without BOT_USERNAME were effectively DOA for /start, /help, /remind, /list, /cancel, /timezone, /groupmode. Move the None check before pattern construction so re.escape only sees a real string. Add a test module (tests/test_text.py) that locks down both branches (bot_username=None accepts bare and "@anybot" suffixes; bot_username set accepts bare and matching suffix), plus baseline strip_bot_mention coverage that was missing. Generated with AI Co-Authored-By: AI <ai@example.com>
fix(text): command_body no longer crashes when bot_username is None
feat(recurrence): phase 3 — show recurrence rule across all UX surfaces
Recurring reminders had no way to say "I'm going on vacation, skip the next one" or "I'm done with this series entirely" from the delivery notification itself. The only escape was /cancel <short_id> which needs the user to type or copy the short id. Phase 4a adds two buttons to the delivery keyboard when reminder.recurrence is set. Renderer: - Add `delivery_recurring_keyboard(short_id, next_day_time_label)` that keeps the three snooze buttons and appends a row with "跳過下次" and "取消整個系列". The existing one-off keyboard is unchanged, so ordinary reminders are not affected. - Add `render_skipped_next` / `render_series_cancelled` used to overwrite the delivery message once the user picks either action. Scheduler: - Pick between the two keyboards based on `reminder.recurrence`. Recurring reminders get the extended keyboard; one-off keeps the three-button variant. Service: - `skip_next_occurrence(chat_id, short_id, actor, now)` runs `next_fire(rule, current_remind_at)` to compute the occurrence *after* the currently scheduled one, then calls `repository.advance_pending` (new) to update remind_at while status is still PENDING. Non-recurring reminders return None so the caller can surface a clear "not applicable" answer. - `cancel_series(chat_id, short_id, actor)` delegates to the existing `repository.cancel` which already enforces the creator guard; the callback layer only needs to reply and repaint. Repository: - `advance_pending` mirrors `snooze` (creator guard, PENDING → new remind_at) but keeps status PENDING and does not touch fired_at. Semantically different from `reschedule` (which is scheduler-owned and transitions FIRING → PENDING). Callback layer: - New `skip_next` and `cancel_series` handlers. Both edit the delivery message in place with cleared keyboard so the same row cannot be triggered twice. Tests: - Service: skip advances one iteration (7/18 → 7/25), skip crosses month boundary (7/25 → 8/1), non-recurring rejected, non-creator rejected, cancel_series marks CANCELLED, non-creator rejected on cancel too. - Scheduler: recurring delivery uses the extended keyboard; one-off delivery does not. Generated with AI Co-Authored-By: AI <ai@example.com>
advance_pending() ran SELECT then UPDATE in the same connection but never checked whether the UPDATE actually hit a row. If scheduler claim_due changed status from PENDING to FIRING between the two statements (different connection / worker thread), the update would be 0 rows affected while the method still returned a "success" Reminder object with the new remind_at — DB and return value out of sync, and the calling flow (skip_next callback) would repaint the delivery card as if the skip succeeded. Check result.rowcount and return None when it is not exactly 1. Also expand the callback error message so users are not misled when the reason is not "not recurring" — the skip flow also returns None for missing reminder, non-creator, and the race case. Regression test: mark reminder as FIRING before calling skip_next_occurrence; assert None is returned and the stored remind_at is unchanged. Generated with AI Co-Authored-By: AI <ai@example.com>
Follow-ups from the phase-4a review: 1. `_skip_next` error text now covers the "在處理中" case that was introduced by the round-1 rowcount guard. Users clicking right at the fire moment (PENDING → FIRING race) will no longer be told they lack permission or that the reminder does not exist. 2. `_cancel_series` error text has the same problem: `cancel_series` delegates to `repository.cancel` which only accepts PENDING, so a click during the fire window returned None with a misleading "not creator / not found" message. Broaden it symmetrically to include "在處理中". 3. `delivery_recurring_keyboard` was copy-pasting the three snooze button rows from `delivery_snooze_keyboard`, which would drift the next time either side edits button text, ordering, or callback payload. Delegate to `delivery_snooze_keyboard` for the base rows and only append the phase-4a row on top. Behaviour is unchanged, verified by the existing scheduler keyboard tests. 4. Add missing renderer tests for `render_skipped_next` and `render_series_cancelled` — they are the primary messages users see after clicking the new buttons, so both content and HTML escaping matter. Coverage now matches the other render_* helpers. Generated with AI Co-Authored-By: AI <ai@example.com>
Same race window we fixed in advance_pending exists in repository.cancel — SELECT the reminder while it is PENDING, then run an UPDATE guarded on status = PENDING. If scheduler.claim_due transitions the row to FIRING between the two statements (different connection), the UPDATE is 0 rows affected but the method still returns a Reminder object with status=CANCELLED synthesised locally. Users see a "already cancelled" reply while the DB row is untouched and continues to fire on the next cycle. Add the same result.rowcount != 1 → return None guard. This is pre-existing but exposed by Phase 4a's cancel-series button (the same call path is also used by /cancel and the detail-page delete, so both benefit). Regression test mirrors the skip_next race test: pre-mark reminder as FIRING, call cancel_series, expect None returned and DB row status still FIRING (not CANCELLED). Generated with AI Co-Authored-By: AI <ai@example.com>
The snooze SQL only accepted status IN (FIRING, FIRED) but the scheduler.tick recurrence branch calls repository.reschedule immediately after send_message, transitioning the row from FIRING back to PENDING before the user has time to click any delivery button. So the "10 分鐘後 / 1 小時後 / 明天 HH:MM" row that phase 4a delegated onto the recurring keyboard has been silently DOA since Phase 1: every click returned None and the reply text mumbled about "not creator or not found" without matching reality. Add PENDING to both the SELECT status filter and the guarded UPDATE so recurring reminders can be snoozed while pending. Behavior for one-off reminders is unchanged (they still snooze from FIRING or FIRED). Add the same rowcount != 1 guard as advance_pending / repository.cancel for the SELECT → UPDATE race window with scheduler.claim_due. Interaction with the recurrence schedule: snoozing overwrites the already-scheduled next fire with `now + delay`. When that snooze fire runs, tick calls next_fire(rule, now) which returns the next occurrence per the original rule, so the recurrence pattern self-heals — the user only sees an extra "remind me later" notification for the current instance. Regression test constructs a PENDING recurring reminder (mirroring the state after tick's reschedule) and confirms 10m snooze returns click_at + 10 minutes. Generated with AI Co-Authored-By: AI <ai@example.com>
cancel() only replaced `status` on the returned Reminder while the UPDATE wrote a fresh `updated_at` to the database. The returned object therefore reported the pre-cancel timestamp, out of sync with the persisted row. Every other update-shaped repository method (snooze, advance_pending, update_title, update_remind_at) already returns updated_at from the mutation moment; cancel was the outlier. Keep the mutation timestamp as a datetime alongside the ISO string we hand to SQLite, then pass it through `replace(...)` on the returned Reminder so callers see the same value that landed in the DB. Regression test asserts the returned reminder's updated_at differs from the pre-cancel row and matches what get_by_short_id reads back. Generated with AI Co-Authored-By: AI <ai@example.com>
The button label is "取消整個系列" and the edit message after success is "已取消整個系列", but the callback toast omitted "整個" and said "已取消系列". Match the button/edit wording so users see consistent wording across the entire click flow. Generated with AI Co-Authored-By: AI <ai@example.com>
…d-cancel feat(recurrence): phase 4a — skip next + cancel series on delivery
* feat(parser): single-turn reject for invalid recurrence markers Before this change, "每個月 45 號 09:00 提醒我" would silently downgrade to a one-off reminder for "tomorrow 09:00" with title "每個月 45 號" — the marker text bleeding into the title while the user's intent (a monthly recurrence) was ignored. Same for "每年 13/25", "每年 2/30", "每年 4/31", etc. The recurrence path already rejected these at validation but simply returned None and let the split parser scavenge whatever it could from the leftovers. Introduce a proper "single-turn reject" signal: - Add RecurrenceError(marker_text, reason) dataclass to models. - Extend ParseResult with recurrence_error field. - _detect_recurrence_marker now distinguishes three outcomes: valid tuple, RecurrenceError, or None. Monthly with any 1..31 values still succeeds (partial-valid tolerated); only all-invalid raises. Yearly reports a specific reason (month out of range vs month max days) using the same leap-year probe as is_valid_month_day. - _try_recurrence_parse propagates RecurrenceError; parse() sets it on ParseResult so callers can react without inspecting internals. - service.preview_parse lets router peek at parse output without any save side-effect; begin_create accepts a pre-computed parse_result to avoid double-parsing after a successful preview. - renderer.render_recurrence_error + response_sender.send_recurrence_error emit the user-visible message. - Router runs preview_parse first: on recurrence_error it sends the error message and returns without touching pending draft/edit sessions, so the user's in-progress work is not clobbered by a typo. Tests cover parser signals for all invalid inputs (monthly 0/45, yearly 13/25, 2/30, 4/31), the partial-valid tolerance path, the happy-path 2/29 leap-day case, router's single-turn reject flow, and the "invalid recurrence does not disturb a pending draft" case. Generated with AI Co-Authored-By: AI <ai@example.com> * fix(service): defensive begin_create for invalid recurrence markers Copilot review round 1 flagged that begin_create() would still build and persist a draft when parse_result carried a recurrence_error. Any entry point that skipped the router's preview_parse guard (e.g. /remind) would regress to the pre-#27 UX: silent draft + "什麼時候提醒?" follow-up. Add RecurrenceRejected result type and short-circuit inside begin_create so the guard sits at the layer closest to the side effect. send_draft_result dispatches it to send_recurrence_error. Also fix parser docstring that said "raise" instead of "return" for RecurrenceError. Generated with AI Co-Authored-By: AI <ai@example.com> --------- Co-authored-by: AI <ai@example.com>
* feat(recurrence): support interval-based rules (每 N 分鐘/小時/天/週)
Extend the RecurrenceRule dataclass with a new INTERVAL period and an
interval_seconds field so users can say "每 15 分鐘提醒我喝水" or
"每 2 週提醒我倒垃圾". INTERVAL rules do not carry a time-of-day; next_fire
just adds the interval to the current tick.
Parser gains RECURRENCE_INTERVAL_RE with unit aliases (天/日, 週/周). Below
INTERVAL_MIN_SECONDS (10 min) the parser emits a RecurrenceError → router
single-turn reject (『每 1 分鐘』太頻繁, 最低支援 10 分鐘). Above the
threshold a valid rule is built and title extraction reuses the existing
"strip marker + 提醒(我/我們/大家)?" flow; a missing title still falls
through to the normal draft prompt.
Storage does not need a migration — recurrence is a JSON blob and
serialize/deserialize gained an INTERVAL branch that omits hour/minute.
format_rule picks the largest evenly-divisible unit so 900s renders as
"每 15 分鐘" and 604800s as "每 1 週".
render_recurrence_error template dropped the date-specific "不是有效的日期"
wrapper; parser reasons are now self-contained (「日期無效,需在 1-31 範圍」/
「太頻繁,最低支援 10 分鐘」) so both date and interval errors read
naturally.
Test count: 203 → 216.
Generated with AI
Co-Authored-By: AI <ai@example.com>
* refactor(delivery): drop snooze buttons from recurring reminder keyboard
Recurring reminders will fire again on schedule, so the snooze buttons
(10 分/1 小時/明天 HH:MM) inherited from one-off delivery add no signal
over 跳過下次: they either overlap ("明天 09:15" on a DAILY 09:00 rule is
a no-op) or drift the cadence (INTERVAL every 15 min + snooze 10 min ends
up on a shifted grid). Trim delivery_recurring_keyboard down to the two
actions that are actually distinct: skip the next occurrence, or cancel
the whole series. Users who need a precise one-shot delay can /remind a
new reminder.
Signature dropped next_day_time_label since the label was only used by
the snooze buttons. Scheduler now branches so it only computes the label
for one-off reminders.
Generated with AI
Co-Authored-By: AI <ai@example.com>
* fix(recurrence): defensive checks + boundary handling for interval
Copilot round 1 flagged four gaps in the INTERVAL feature:
1. RECURRENCE_INTERVAL_RE matches n=0, which falls into the "太頻繁" bucket
and emits a misleading reason. Split the check: seconds<=0 → "間隔需為
正整數"; below INTERVAL_MIN_SECONDS → "太頻繁". Kept \d+ so 「每 0 分鐘」
still hits a marker path (a stricter [1-9]\d* would silently fall through
to "什麼時候提醒?", worse UX).
2. Very large n (「每 99999999999 週」) raises OverflowError inside
`now + timedelta`, crashing begin_create. Catch in the INTERVAL branch
of `_try_recurrence_parse` and return RecurrenceError.
3. `serialize_rule` accepted INTERVAL rules without validating
interval_seconds, letting bad data reach the DB and blow up
deserialize with `int(None)`. Align validation with next_fire /
format_rule and raise early. Same guard for missing hour/minute
on non-interval rules.
4. Optional hour/minute made `next_fire(RecurrenceRule(period=DAILY))`
raise TypeError from `after.replace(hour=None)`. Add explicit None
guard on the non-INTERVAL path so callers see ValueError with a
readable message.
Test count: 216 → 221.
Generated with AI
Co-Authored-By: AI <ai@example.com>
* fix(recurrence): fail-fast on non-divisible interval_seconds
Copilot round 2 flagged that `_format_interval_seconds` silently
truncates to minutes when interval_seconds does not divide evenly into
any supported unit (601s → 「每 10 分鐘」 while the schedule is actually
601s). Parser guarantees a 60-multiple so this is unreachable via the
normal path, but if a bad value ever reaches format_rule (e.g. DB hand
edit), raising ValueError is more debuggable than a silently drifting
UI. Aligns with the defensive raise pattern already used in next_fire /
serialize_rule.
Generated with AI
Co-Authored-By: AI <ai@example.com>
* style(parser): unify rejection prefix on yearly impossible-date reason
Copilot round 3 noted that yearly "day exceeds month max" produced a
reason like "2 月最多 29 天" — reads as a statement of fact instead of a
rejection, unlike monthly ("日期無效,需在 1-31 範圍") and interval
("太頻繁,最低支援 10 分鐘") which carry their own reject-tone prefix.
Prepend "日期無效" so render_recurrence_error emits a consistent
message: 「『每年 2/30』日期無效,2 月最多 29 天。」.
Generated with AI
Co-Authored-By: AI <ai@example.com>
---------
Co-authored-by: AI <ai@example.com>
- __version__ 0.3.1 → 0.4.0 (pyproject.toml, __init__.py, uv.lock). - CHANGELOG documents recurring reminders (data model, parser, UX), INTERVAL period, single-turn reject for invalid markers, recurring delivery keyboard slim-down, and defensive fixes across the storage layer. - README features section covers recurring examples + clarifies that snooze buttons are one-off only. Generated with AI Co-authored-by: AI <ai@example.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
v0.4.0 主打 週期性提醒,涵蓋規則資料模型、自然語言 parser、確認卡 / 列表 / 詳情 / 到期訊息四個 UX surface、到期後的按鈕行為,以及無效輸入的單輪拒絕與 defensive validation。
Added
每天 09:00、每週一 09:00、每個月 1, 18, 25 號 09:00、每年 12/25 08:00都能一句自然語言建立,到期後自動排下一次。每 N 分鐘 / 小時 / 天 / 週(含「日 / 週 / 周」別名),下限 10 分鐘。首次觸發等於now + interval(無 time-of-day 概念)。[跳過下次][取消整個系列],取代不合語意的 snooze。[重複]前綴:/list對重複提醒特別標示。每個月 45 號/每年 2/30/每 1 分鐘/每 0 分鐘/ 超大 N 都會回具體錯誤原因並要求重打,不建 draft、不追問。Changed
render_recurrence_error模板改為 reason 自帶語意,讓 date / interval 錯誤訊息語氣一致。Fixed
command_body(bot_username=None)crash(v0.3.1 預存 bug)advance_pending / cancel / snooze全部加 rowcount 檢查snooze對 recurring reminder 之前 DOA — SQL 加 PENDING 狀態允許claim_due用julianday()做絕對時間比較next_fireformat_rule/next_fire/serialize_ruledefensive validation gapsPersistence
reminders.recurrence(JSON blob)reminder_drafts.recurrence一句話
v0.3.1 之後 dev 累積 30 個 commit,含 6 個 feature/fix PR(#22 #23 #24 #25 #26 #27 #28 #29),222 tests 綠。
Test plan
make check(ruff + 222 unit tests + smoke flow)綠v0.4.0tag 與 GitHub Release