diff --git a/reference/projects/zulip-scheduler-bot.md b/reference/projects/zulip-scheduler-bot.md index 06bae86..9f7133a 100644 --- a/reference/projects/zulip-scheduler-bot.md +++ b/reference/projects/zulip-scheduler-bot.md @@ -43,8 +43,9 @@ Build a Zulip bot in the python-zulip-api repo that finds common meeting times a - Calendar field now used for ICS feed URLs (removed old plain-text weekly availability from profile fields) ### New architecture -- **Registration:** `@**Scheduler** register [timezone]` — self-registration, stores ICS URL + optional timezone in `bot_handler.storage` (key: user_id, value: `{"url": "...", "tz": "America/New_York"}`). Timezone defaults to inferred from feed if omitted, UTC as final fallback. -- **Schedule:** `@**Scheduler** schedule @**Alice** @**Bob**` — requires explicit `schedule` keyword; bare `60 @**Alice**` returns None (useful to avoid ambiguity with future subcommands) +- **Registration:** `@**Scheduler** register [timezone]` OR just DM the bot `` (bare URL). Self-registration, stores ICS URL + optional timezone in `bot_handler.storage` (key: user_id, value: `{"url": "...", "tz": "America/New_York"}`). Timezone defaults to inferred from feed if omitted, UTC as final fallback. +- **Schedule:** `@**Scheduler** schedule @**Alice** @**Bob**` — name is required (e.g., `standup`, `1:1`). Returns slot info + zform widget with "Create" button. +- **Confirm:** `@**Scheduler** confirm ` — sent by zform button click, creates meeting and returns `meet.jit.si/` link. - **Storage:** `bot_handler.storage` (Zulip's built-in key-value store). Each user gets an entry `{"url": "...", "tz": "..."}` - **Validation:** Register command fetches ICS URL on entry to validate reachable/parseable before storing - **Error handling:** Storage errors raise `RuntimeError` with user-facing messages (no silent failures) @@ -75,7 +76,7 @@ Build a Zulip bot in the python-zulip-api repo that finds common meeting times a - None — multi-timezone display implemented (see "How it works" below) ## How it works (current v1 behavior) -1. User mentions the bot with the `schedule` keyword, duration in minutes, and @mentions of participants: `@**Scheduler** schedule 30 @**Alice** @**Bob**` +1. User mentions the bot with `schedule @mentions`: `@**Scheduler** schedule standup 30 @**Alice** @**Bob**` 2. Bot parses @mentions — supports both `@**Name**` (name only) and `@**Name|123**` (with user ID). Uses `user_id` directly when present, falls back to `client.get_users()` name lookup when not. 3. Bot looks up each user's ICS URL + timezone from `bot_handler.storage` 4. Bot fetches each participant's ICS feed, extracts busy events, converts to UTC, and computes free time within working hours (09:00-17:00 local) @@ -83,6 +84,8 @@ Build a Zulip bot in the python-zulip-api repo that finds common meeting times a 6. Returns the earliest slot that fits the requested duration, displayed in each unique participant timezone (deduped) - Same timezone (both NY): `9:00 AM EST – 9:30 AM EST (30 min) with Alice, Bob.` - Different timezones (NY + LA): `9:00 AM EST – 9:30 AM EST / 6:00 AM PST – 6:30 AM PST (30 min) with Alice, Bob.` +7. **Zform widget:** Bot also sends a zform widget with a "Create" button. Clicking sends `confirm ` back. +8. **Confirm handler:** On confirm, bot replies with a `meet.jit.si/` link (e.g., `meet.jit.si/standup`). ## Key API calls - `client.get_users()` - map user names to user IDs, returns `email` for each member. Used as fallback when @mention lacks user_id (common in real orgs; testing orgs may include user_id). @@ -104,8 +107,8 @@ Bot directory: zulip_bots/zulip_bots/bots/scheduler/ ## Bot Structure - `range_utils.py` — pure range math: `merge_ranges`, `intersect_ranges`, `subtract_ranges`, `find_earliest_slot`, `snap_to_granularity`, `TimeRange` type - `ics.py` — ICS fetching + conversion: `fetch_ics_events`, `busy_to_free`, `BusyEvent` type, `extract_timezone()`, `UTC` constant. Uses absolute imports (bot runner requirement). -- `scheduler.py` — orchestration + bot handler: `find_common_slot`, `parse_command`, `working_hours_to_utc`, `minutes_to_time`, `SchedulerHandler`, constants. Uses absolute imports (bot runner requirement). -- `test_scheduler.py` — 34 tests (adds city-name/IANA timezone resolution tests, register reply format, bad-tz error handling; covers same items as before plus tz resolution edge cases) +- `scheduler.py` — orchestration + bot handler: `find_common_slot`, `parse_command`, `working_hours_to_utc`, `minutes_to_time`, `SchedulerHandler`, `sanitize_meeting_name()`, `format_schedule_widget()`, `ConfirmCommand`, `ScheduleCommand` (with `name` field). Uses absolute imports (bot runner requirement). +- `test_scheduler.py` — 40 tests (covers bare URL registration, schedule with name field, ConfirmCommand parsing, zform widget rendering, confirm handler flow, and all previous timezone/cron parsing tests) - `doc.md` — bot documentation - `__init__.py` — package init - `requirements.txt` — bot runner dependency file (required even if empty). Scheduler bot contains `icalendar` and `libtime`. @@ -130,16 +133,27 @@ Bot directory: zulip_bots/zulip_bots/bots/scheduler/ - **Granularity snapping:** `snap_to_granularity()` snaps free range starts up to next 30-min boundary. `DEFAULT_GRANULARITY_MINUTES = 30`. Applied after intersect, before finding earliest slot. - **Parsing change:** Old `TZ_RE` regex only matched `X/Y` format; now remainder after URL is passed as-is to `resolve_timezone()`. -## Command Parsing & Types (2026-08-12, updated 2026-08-12) -- `@dataclass` types for clarity: `Participant` (events, timezone), `RegisterCommand` (url, timezone), `ScheduleCommand` (duration, participants), `Mention` (name, user_id: Optional[int]) -- `parse_command()` returns `Optional[RegisterCommand | ScheduleCommand]` -- `handle_message()` dispatches via `isinstance` checks +## Command Parsing & Types (2026-08-12, updated 2026-08-13) +- `@dataclass` types for clarity: `Participant` (events, timezone), `RegisterCommand` (ics_url), `ScheduleCommand` (name, duration, participants), `ConfirmCommand` (name, day, start_min, end_min), `Mention` (name, user_id: Optional[int]) +- `parse_command()` returns `Optional[RegisterCommand | ScheduleCommand | ConfirmCommand]` +- `handle_message()` dispatches via `isinstance` checks to `RegisterCommand`, `ScheduleCommand`, and `ConfirmCommand` handlers - `ics_url` uses snake_case (Pythonic convention) -- `schedule` keyword is now **required** for scheduling commands (`parse_command()` returns None for bare ` @mention` patterns) — prevents ambiguity with future subcommands +- **Bare URL handling:** After checking "register " prefix, checks if input starts with bare URL (matches `URL_RE`). If so, treats as `RegisterCommand`. Falls through to "schedule" check otherwise. +- **Schedule format:** `schedule @mentions` — name is now required (e.g., `standup`, `1:1`, `retro`) +- **Confirm format:** `confirm ` — sent by zform button, parsed by `parse_command` +- `schedule` keyword required (prevents ambiguity with bare ` @mention`) +- `confirm` keyword required (parsed separately from schedule) + +## Recent Enhancements (2026-08-13) +- **Bare URL registration:** Users can DM the bot just a URL (no "register" prefix) to register. `parse_command` checks if input matches `URL_RE` after the "register " prefix check and before "schedule" check. Non-ICS URLs gracefully fail with timezone extraction error. +- **Schedule command with name field:** Format changed from `schedule @mentions` to `schedule @mentions`. Example: `@**Scheduler** schedule standup 30 @**Alice** @**Bob**` +- **Zform widget buttons:** When a slot is found, bot sends a zform widget with a "Create" button. Button's `reply` field sends `confirm ` back to the bot. +- **ConfirmCommand:** New command type handling button clicks. On confirm, bot replies with a `meet.jit.si/` link. +- **`sanitize_meeting_name()` helper:** Sanitizes meeting names for Jitsi URL slug (alphanumeric + hyphens, no spaces). +- **`format_schedule_widget()` helper:** Formats the zform widget JSON payload with Create button. ## Planned Features - **Email invites:** Send email to participants with meeting details (user email available from `get_users()` response) -- **Jitsi URL construction:** Generate Jitsi meeting URL from event name and auto-include in invite ## Requirements fix requirements.txt line 11 had `#egg=zulint==1.0.0` which newer pip (26.1.2) rejects. Fixed by removing `==1.0.0`. @@ -160,4 +174,4 @@ requirements.txt line 11 had `#egg=zulint==1.0.0` which newer pip (26.1.2) rejec - Real calendar data confirmed: TZ conversion working correctly, event staleness observable (feeds updating incrementally) - Testing philosophy: minimal tests focused on edge cases and integration, not unit-testing obvious utilities (merging/intersecting ranges verified via `TestFindCommonSlot` integration) - `BotTestCase` (python-zulip-api built-in test harness) covers full flow with mocked ICS events — sufficient for all integration tests. Manual REPL testing with real feeds used for verification only. -- 34 tests cover: tz conversions, granularity snapping (30-min), core scheduling, command parsing (register/schedule with keyword requirement), bot handler integration, mention format handling (both with/without user_id), storage error handling, multi-tz display formatting (deduplication, same-tz vs different-tz), same-timezone dedup edge case (America/New_York + America/Toronto), timezone resolution (IANA names, city names, bad input), register reply format +- 40 tests cover: tz conversions, granularity snapping (30-min), core scheduling, command parsing (bare URL register, schedule with name, confirm), bot handler integration (zform widget rendering, confirm handler flow), mention format handling (both with/without user_id), storage error handling, multi-tz display formatting (deduplication, same-tz vs different-tz), same-timezone dedup edge case (America/New_York + America/Toronto), timezone resolution (IANA names, city names, bad input), register reply format, non-ICS URL edge case