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 <noreply@anthropic.com>
This commit is contained in:
Harry Culpan 2026-07-11 19:18:02 -04:00
parent 01a4d51543
commit 7370d1291e
5 changed files with 130 additions and 9 deletions

View file

@ -5,6 +5,10 @@ FROM alpine:latest
ENV APP_NAME="areweplaying" ENV APP_NAME="areweplaying"
ENV APP_DIR="/app" 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 # Create the working directory
WORKDIR $APP_DIR WORKDIR $APP_DIR
@ -16,10 +20,11 @@ COPY awp-cli /app/awp-cli
RUN chmod +x /app/areweplaying RUN chmod +x /app/areweplaying
RUN chmod +x /app/awp-cli 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, # Install the crontab: runs `./awp-cli check` every 2 minutes and
# logging output to /app/logs/cron.log (logs/ is mounted at runtime). # `./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 COPY crontab.txt /app/crontab.txt
RUN crontab /app/crontab.txt RUN crontab /app/crontab.txt

83
cmd/cli/cmd/gameday.go Normal file
View file

@ -0,0 +1,83 @@
/*
Copyright © 2024 NAME HERE <EMAIL ADDRESS>
*/
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")
}

View file

@ -1 +1,2 @@
*/2 * * * * cd /app && ./awp-cli check >> /app/logs/cron.log 2>&1 */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

View file

@ -16,5 +16,6 @@
], ],
"inc_days": 7, "inc_days": 7,
"status": "planned", "status": "planned",
"password": "ABC123" "password": "ABC123",
"last_notice_sent_date": ""
} }

View file

@ -23,6 +23,7 @@ type Session struct {
IncrementDays int `json:"inc_days"` IncrementDays int `json:"inc_days"`
Status string `json:"status"` Status string `json:"status"`
Password string `json:"password"` Password string `json:"password"`
LastNoticeSentDate string `json:"last_notice_sent_date,omitempty"`
} }
func (s *Session) GMEmail() string { func (s *Session) GMEmail() string {
@ -64,6 +65,36 @@ func (s *Session) FormatStatusUpdate(playerName, newStatus, source string) (stri
return subject, sb.String() 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 { func (s *Session) GetPlayerByEmail(addr string) *Player {
for i, p := range s.Players { for i, p := range s.Players {
if addr == p.Email { if addr == p.Email {