Files
go-sip/internal/callwindow/policy.go
T

159 lines
5.2 KiB
Go

package callwindow
import (
"errors"
"fmt"
"time"
)
// WeeklySchedule is a Shanghai-local, half-open weekly timetable. A period
// crossing midnight must be represented by two windows on adjacent days.
type WeeklySchedule struct {
TimeZone string `json:"time_zone"`
WeeklyWindows map[string][]Window `json:"weekly_windows"`
}
type Window struct {
Start string `json:"start"`
End string `json:"end"`
}
type TaskSchedule struct {
WeeklySchedule
StartsAt string `json:"starts_at"`
EndsAt string `json:"ends_at"`
ExcludedDates []string `json:"excluded_dates"`
}
var weekdays = [...]string{"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"}
var ErrWindowClosed = errors.New("outside the allowed calling window")
// Evaluate returns the earliest time at which the task/line intersection
// closes. A rejected command must not wait for the next window or choose a
// different line after selection.
func Evaluate(task TaskSchedule, line WeeklySchedule, at time.Time) (time.Time, error) {
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return time.Time{}, fmt.Errorf("load outbound time zone: %w", err)
}
if err := task.WeeklySchedule.validate(); err != nil {
return time.Time{}, fmt.Errorf("task schedule: %w", err)
}
if err := line.validate(); err != nil {
return time.Time{}, fmt.Errorf("line schedule: %w", err)
}
var end time.Time
if task.StartsAt != "" {
start, err := time.Parse(time.RFC3339, task.StartsAt)
if err != nil {
return time.Time{}, fmt.Errorf("parse task starts_at: %w", err)
}
if at.Before(start) {
return time.Time{}, fmt.Errorf("%w: task is before its validity period", ErrWindowClosed)
}
if task.EndsAt != "" {
end, err = time.Parse(time.RFC3339, task.EndsAt)
if err != nil {
return time.Time{}, fmt.Errorf("parse task ends_at: %w", err)
}
if !start.Before(end) {
return time.Time{}, errors.New("task schedule starts_at must precede ends_at")
}
}
} else if task.EndsAt != "" {
var err error
end, err = time.Parse(time.RFC3339, task.EndsAt)
if err != nil {
return time.Time{}, fmt.Errorf("parse task ends_at: %w", err)
}
}
if !end.IsZero() && !at.Before(end) {
return time.Time{}, fmt.Errorf("%w: task is past its validity period", ErrWindowClosed)
}
seen := make(map[string]struct{}, len(task.ExcludedDates))
for _, date := range task.ExcludedDates {
parsed, err := time.Parse("2006-01-02", date)
if err != nil || parsed.Format("2006-01-02") != date {
return time.Time{}, fmt.Errorf("invalid excluded date %q", date)
}
if _, duplicate := seen[date]; duplicate {
return time.Time{}, fmt.Errorf("duplicate excluded date %q", date)
}
seen[date] = struct{}{}
}
if _, excluded := seen[at.In(loc).Format("2006-01-02")]; excluded {
return time.Time{}, fmt.Errorf("%w: task excludes the Shanghai calendar date", ErrWindowClosed)
}
taskEnd, err := task.WeeklySchedule.endAt(at, loc)
if err != nil {
return time.Time{}, fmt.Errorf("task schedule: %w", err)
}
lineEnd, err := line.endAt(at, loc)
if err != nil {
return time.Time{}, fmt.Errorf("line schedule: %w", err)
}
if lineEnd.Before(taskEnd) {
taskEnd = lineEnd
}
if !end.IsZero() && end.Before(taskEnd) {
taskEnd = end
}
return taskEnd, nil
}
func (schedule WeeklySchedule) validate() error {
if schedule.TimeZone != "Asia/Shanghai" || len(schedule.WeeklyWindows) != len(weekdays) {
return errors.New("schedule must define all seven weekdays in Asia/Shanghai")
}
for _, name := range weekdays {
windows, exists := schedule.WeeklyWindows[name]
if !exists {
return fmt.Errorf("missing weekday %s", name)
}
previousEnd := 0
for _, window := range windows {
start, err := minuteOfDay(window.Start, false)
if err != nil {
return fmt.Errorf("%s start: %w", name, err)
}
end, err := minuteOfDay(window.End, true)
if err != nil {
return fmt.Errorf("%s end: %w", name, err)
}
if start >= end || start < previousEnd {
return fmt.Errorf("%s windows must be ordered, disjoint, and split at midnight", name)
}
previousEnd = end
}
}
return nil
}
func minuteOfDay(clock string, allowEndOfDay bool) (int, error) {
if len(clock) != 5 || clock[2] != ':' || clock[0] < '0' || clock[0] > '9' || clock[1] < '0' || clock[1] > '9' || clock[3] < '0' || clock[3] > '9' || clock[4] < '0' || clock[4] > '9' {
return 0, fmt.Errorf("invalid clock time %q", clock)
}
hours := int(clock[0]-'0')*10 + int(clock[1]-'0')
minutes := int(clock[3]-'0')*10 + int(clock[4]-'0')
if minutes > 59 || hours > 24 || hours == 24 && (minutes != 0 || !allowEndOfDay) {
return 0, fmt.Errorf("invalid clock time %q", clock)
}
return hours*60 + minutes, nil
}
func (schedule WeeklySchedule) endAt(at time.Time, loc *time.Location) (time.Time, error) {
local := at.In(loc)
windows := schedule.WeeklyWindows[weekdays[local.Weekday()]]
minute := local.Hour()*60 + local.Minute()
for _, window := range windows {
start, _ := minuteOfDay(window.Start, false) // validated above
end, _ := minuteOfDay(window.End, true)
if minute >= start && minute < end {
midnight := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, loc)
return midnight.Add(time.Duration(end) * time.Minute), nil
}
}
return time.Time{}, ErrWindowClosed
}