omnigent-ai--omnigent
204 行
8.2 KiB
Python
204 行
8.2 KiB
Python
#!/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 Mon–Fri 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()
|