Files
2026-07-13 13:12:00 +08:00

204 lines
8.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Daily Discord-watch rotation reminder.
Picks the person on watch for the current day and pings them in Slack on the
morning of *their* local timezone. The rotation is deterministic — the
assignee is a function of the date and the person's position in the list — so
there is no state to store anywhere.
The GitHub Actions workflow wakes at a couple of fixed UTC times (one per
timezone's morning). On each run the day's assignee is pinged only if it's
currently morning where they live; if not, the run for their timezone's
morning handles them. Our timezones are far enough apart that only one is ever
in its morning at a time, so at most one person is pinged per run.
Set SLACK_WEBHOOK_URL to post for real. Leave it unset for a dry run that just
prints what it would do — handy for testing the rotation order without Slack.
"""
from __future__ import annotations
import datetime
import json
import os
import urllib.error
import urllib.request
from dataclasses import dataclass
from zoneinfo import ZoneInfo
# Each cron run is one timezone's morning scan: we ping today's assignee only
# if it's currently morning where they are. A run that's morning in SF is night
# in Singapore and vice versa, so at most one timezone matches per run. Morning
# is a band rather than an exact hour, which absorbs both daylight saving and
# GitHub's frequently-delayed cron schedule — a run that fires a few hours late
# still counts as that person's morning. The band starts at 05:00 (not
# midnight) so a delayed *other* timezone's cron spilling past local midnight
# isn't mistaken for this timezone's morning, which would double-ping.
MORNING_START_HOUR = 5
MORNING_END_HOUR = 12
# Skip Saturdays and Sundays (in each person's local time). The rotation also
# advances by workdays only, so Friday hands off straight to Monday.
WEEKDAYS_ONLY = True
# Rotation anchor: workday 0 is this date. Any Monday works; it only sets the
# phase of the cycle, not who is in it.
EPOCH = datetime.date(2026, 1, 5) # a Monday
@dataclass(frozen=True)
class Person:
name: str # for logs / dry-run output only
slack_id: str # Slack member ID, e.g. "U01ABC2DEF" (NOT the display name)
tz: str # IANA timezone name, e.g. "America/Los_Angeles"
# Out-of-office spans as inclusive (start, end) ISO date pairs, e.g.
# (("2026-07-13", "2026-07-17"),). On any OOO day the person is skipped and
# the next available person covers; the OOO person keeps their later slots.
ooo: tuple[tuple[str, str], ...] = ()
# Rotation order. Slack member IDs (profile -> ⋮ More -> Copy member ID) and
# each person's IANA timezone.
PEOPLE: list[Person] = [
Person("Aravind Segu", "U01A12R8NUR", "America/Los_Angeles"),
Person("Bryan Qiu", "U05KA5T983Y", "America/Los_Angeles"),
Person("Daniel Lok", "U060CNWNHSQ", "Asia/Singapore"),
Person("Dhruv Gupta", "U0A76097E1F", "America/Los_Angeles"),
Person("Edwin He", "U077B1V6WQJ", "America/Los_Angeles"),
Person("Pat Sukprasert", "U05HRKWFY81", "Asia/Singapore"),
Person("Sabhya Chhabria", "U07A1KQDXAB", "America/Los_Angeles"),
Person("Serena Ruan", "U0571L5KNLR", "Asia/Singapore"),
Person("Shivam Mittal", "U09FZKX9S6B", "America/Los_Angeles"),
Person("Tomu Hirata", "U07TX4PR5MZ", "Asia/Singapore"),
Person("Zeyi (Rice) Fan", "U09L5HT4CH0", "America/Los_Angeles"),
]
def _workdays_between(start: datetime.date, end: datetime.date) -> int:
"""Number of MonFri days in [start, end). Negative if end precedes start."""
if end < start:
return -_workdays_between(end, start)
full_weeks, extra = divmod((end - start).days, 7)
count = full_weeks * 5
for i in range(extra):
if (start + datetime.timedelta(days=full_weeks * 7 + i)).weekday() < 5:
count += 1
return count
def is_ooo(person: Person, local_date: datetime.date) -> bool:
"""Whether person is out of office on local_date (inclusive spans)."""
for start, end in person.ooo:
if datetime.date.fromisoformat(start) <= local_date <= datetime.date.fromisoformat(end):
return True
return False
def assignee_for(local_date: datetime.date) -> Person | None:
"""The person on watch for a given local workday, or None if all are OOO.
Indexed by the number of workdays since EPOCH (which is itself a Monday),
so weekends advance nobody and Friday hands off directly to Monday. If the
slot's person is OOO, the next available person covers — probing forward so
coverage stays a pure function of the date (no stored state). Only
meaningful for weekdays; weekends are filtered out before this is called.
"""
workday_number = _workdays_between(EPOCH, local_date)
for offset in range(len(PEOPLE)):
person = PEOPLE[(workday_number + offset) % len(PEOPLE)]
if not is_ooo(person, local_date):
return person
return None # everyone is OOO that day
def whose_turn_now(now_utc: datetime.datetime) -> Person | None:
"""Return the person to ping right now, or None if it isn't anyone's morning.
Each person is evaluated in their own timezone: it must be a weekday morning
(before noon) there, and today's rotation slot must land on them. Since our
timezones are far enough apart that only one is ever in its morning at a
time, at most one person matches. A person missed by a late/early run is
picked up by the next run that lands in their morning.
"""
for person in PEOPLE:
local = now_utc.astimezone(ZoneInfo(person.tz))
if not (MORNING_START_HOUR <= local.hour < MORNING_END_HOUR):
continue
if WEEKDAYS_ONLY and local.weekday() >= 5: # 5=Sat, 6=Sun
continue
if assignee_for(local.date()) == person:
return person
return None
class SlackPostError(RuntimeError):
"""Raised when the Slack POST fails, without exposing the webhook URL."""
def post_to_slack(webhook_url: str, person: Person) -> None:
text = (
f"<@{person.slack_id}> you're on *Discord watch* today \U0001f440 "
f"— please keep an eye on the channel."
)
payload = json.dumps({"text": text}).encode()
req = urllib.request.Request(
webhook_url,
data=payload,
headers={"Content-Type": "application/json"},
)
# Catch and re-raise without the URL: urllib errors stringify the full
# webhook URL, which must never reach the Actions log or error output.
try:
with urllib.request.urlopen(req, timeout=30) as resp:
resp.read()
except urllib.error.HTTPError as exc:
raise SlackPostError(f"Slack returned HTTP {exc.code} {exc.reason}") from None
except urllib.error.URLError as exc:
raise SlackPostError(f"could not reach Slack: {exc.reason}") from None
def _report_todays_assignees(now_utc: datetime.datetime) -> None:
"""Log who's on watch for each timezone's current local date.
Runs regardless of the morning window so a manual run is always
informative, even outside anyone's ping window.
"""
for tz in sorted({p.tz for p in PEOPLE}):
local = now_utc.astimezone(ZoneInfo(tz))
if local.weekday() >= 5: # 5=Sat, 6=Sun
who = "nobody (weekend)"
else:
person = assignee_for(local.date())
who = person.name if person else "nobody (all OOO)"
print(f" {tz}: {local:%Y-%m-%d %a} -> {who}")
def main() -> None:
now_utc = datetime.datetime.now(datetime.timezone.utc)
print(f"Today's watch by timezone (as of {now_utc:%Y-%m-%d %H:%M UTC}):")
_report_todays_assignees(now_utc)
person = whose_turn_now(now_utc)
if person is None:
print(f"{now_utc:%Y-%m-%d %H:%M UTC}: nobody's on watch right now, nothing to do.")
return
local = now_utc.astimezone(ZoneInfo(person.tz))
webhook_url = os.environ.get("SLACK_WEBHOOK_URL")
if not webhook_url:
print(
f"[dry run] Would ping {person.name} ({person.slack_id}) "
f"— it's {local:%Y-%m-%d %H:%M} in {person.tz}. "
f"Set SLACK_WEBHOOK_URL to post for real."
)
return
post_to_slack(webhook_url, person)
print(f"Pinged {person.name} ({person.slack_id}) at {local:%Y-%m-%d %H:%M %Z}.")
if __name__ == "__main__":
main()