From 7370d1291e0a560f0d03dd513786f06b2ddfd4cd Mon Sep 17 00:00:00 2001 From: Harry Culpan Date: Sat, 11 Jul 2026 19:18:02 -0400 Subject: [PATCH] Add noon-of-gameday GM status email via awp-cli gameday-notice Adds a cron-driven CLI command that emails the GM a player attendance summary at noon on the session date, skipping automatically if the game was canceled or today isn't game day, and tracking last-sent date so it won't double-send. Co-Authored-By: Claude Sonnet 5 --- Dockerfile | 11 ++++-- cmd/cli/cmd/gameday.go | 83 ++++++++++++++++++++++++++++++++++++++++++ crontab.txt | 1 + example_players.json | 3 +- pkg/data/data.go | 41 ++++++++++++++++++--- 5 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 cmd/cli/cmd/gameday.go diff --git a/Dockerfile b/Dockerfile index 4ab2449..f9d81db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,10 @@ FROM alpine:latest ENV APP_NAME="areweplaying" ENV APP_DIR="/app" +# Cron times (e.g. the noon gameday-notice job) are evaluated in this +# timezone. Without tzdata + TZ, Alpine's cron defaults to UTC. +ENV TZ="America/New_York" + # Create the working directory WORKDIR $APP_DIR @@ -16,10 +20,11 @@ COPY awp-cli /app/awp-cli RUN chmod +x /app/areweplaying RUN chmod +x /app/awp-cli -RUN apk add --no-cache cronie +RUN apk add --no-cache cronie tzdata -# Install the crontab: runs `./awp-cli check` every 2 minutes, -# logging output to /app/logs/cron.log (logs/ is mounted at runtime). +# Install the crontab: runs `./awp-cli check` every 2 minutes and +# `./awp-cli gameday-notice` daily at noon, logging output to +# /app/logs/cron.log (logs/ is mounted at runtime). COPY crontab.txt /app/crontab.txt RUN crontab /app/crontab.txt diff --git a/cmd/cli/cmd/gameday.go b/cmd/cli/cmd/gameday.go new file mode 100644 index 0000000..25b1207 --- /dev/null +++ b/cmd/cli/cmd/gameday.go @@ -0,0 +1,83 @@ +/* +Copyright © 2024 NAME HERE +*/ +package cmd + +import ( + "log" + "time" + + "github.com/hculpan/areweplaying/pkg/data" + "github.com/spf13/cobra" +) + +var gameDayForce bool + +// gameDayNoticeCmd represents the gameday-notice command +var gameDayNoticeCmd = &cobra.Command{ + Use: "gameday-notice", + Short: "Emails the GM a player status summary if today is game day", + Long: `Checks whether today is the date of the next session and, if the +session is still "planned", emails the GM a summary of current player +attendance. Intended to be run once a day, e.g. by cron at noon. Does +nothing if the session was canceled, if today isn't the session date, or +if the notice was already sent today (unless --force is given).`, + RunE: func(cmd *cobra.Command, args []string) error { + session, err := data.ReadSessionData() + if err != nil { + log.Default().Printf("ERROR reading session data: %s", err) + return err + } + + sessionDate, err := time.ParseInLocation(data.DATE_FORMAT, session.Date, time.Local) + if err != nil { + log.Default().Printf("ERROR parsing session date %q: %s", session.Date, err) + return err + } + + today := time.Now().Local() + isGameDay := sessionDate.Year() == today.Year() && sessionDate.YearDay() == today.YearDay() + todayStr := today.Format("2006-01-02") + + if !isGameDay { + log.Default().Printf("Skipping game day notice: today is not the session date (%s)", session.Date) + return nil + } + + if session.Status != "planned" { + log.Default().Printf("Skipping game day notice: session status is %q", session.Status) + return nil + } + + if !gameDayForce && session.LastNoticeSentDate == todayStr { + log.Default().Printf("Skipping game day notice: already sent today (%s)", todayStr) + return nil + } + + gmEmail := session.GMEmail() + if gmEmail == "" { + log.Default().Printf("Skipping game day notice: no GM email configured") + return nil + } + + subject, body := session.FormatGameDayNotice() + if err := sendNotice(gmEmail, subject, body); err != nil { + log.Default().Printf("ERROR sending game day notice: %s", err) + return err + } + + session.LastNoticeSentDate = todayStr + if err := data.PersistSession(session); err != nil { + log.Default().Printf("ERROR persisting session: %s", err) + return err + } + + log.Default().Printf("Sent game day notice to %s", gmEmail) + return nil + }, +} + +func init() { + rootCmd.AddCommand(gameDayNoticeCmd) + gameDayNoticeCmd.Flags().BoolVar(&gameDayForce, "force", false, "Send the notice even if it was already sent today") +} diff --git a/crontab.txt b/crontab.txt index e7b3ec4..58c8ad4 100644 --- a/crontab.txt +++ b/crontab.txt @@ -1 +1,2 @@ */2 * * * * cd /app && ./awp-cli check >> /app/logs/cron.log 2>&1 +0 12 * * * cd /app && ./awp-cli gameday-notice >> /app/logs/cron.log 2>&1 diff --git a/example_players.json b/example_players.json index 3969714..feba188 100644 --- a/example_players.json +++ b/example_players.json @@ -16,5 +16,6 @@ ], "inc_days": 7, "status": "planned", - "password": "ABC123" + "password": "ABC123", + "last_notice_sent_date": "" } diff --git a/pkg/data/data.go b/pkg/data/data.go index 87ccffb..911bd4a 100644 --- a/pkg/data/data.go +++ b/pkg/data/data.go @@ -18,11 +18,12 @@ type Player struct { } type Session struct { - Date string `json:"date"` - Players []Player `json:"players"` - IncrementDays int `json:"inc_days"` - Status string `json:"status"` - Password string `json:"password"` + Date string `json:"date"` + Players []Player `json:"players"` + IncrementDays int `json:"inc_days"` + Status string `json:"status"` + Password string `json:"password"` + LastNoticeSentDate string `json:"last_notice_sent_date,omitempty"` } func (s *Session) GMEmail() string { @@ -64,6 +65,36 @@ func (s *Session) FormatStatusUpdate(playerName, newStatus, source string) (stri return subject, sb.String() } +func (s *Session) FormatGameDayNotice() (string, string) { + subject := fmt.Sprintf("Game Day Status Check: %s", s.Date) + + var yes, no, unknown int + for _, p := range s.Players { + switch p.Attending { + case "yes": + yes++ + case "no": + no++ + default: + unknown++ + } + } + + var sb strings.Builder + fmt.Fprintf(&sb, "Today is game day (%s). Here is the current player status.\n\n", s.Date) + fmt.Fprintf(&sb, "Attending: %d | Not attending: %d | No response: %d\n\n", yes, no, unknown) + sb.WriteString("Current player status:\n") + for _, p := range s.Players { + role := "" + if p.Gm { + role = " (GM)" + } + fmt.Fprintf(&sb, " %s%s: %s\n", p.Name, role, p.Attending) + } + + return subject, sb.String() +} + func (s *Session) GetPlayerByEmail(addr string) *Player { for i, p := range s.Players { if addr == p.Email {