mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-06-09 16:14:55 +00:00
6fa01ba5f1
Phase 6e (final sub-phase of port-plan Phase 06). LoL esports match
schedule via lolesports.com's persisted API.
- internal/modules/lolschedule:
- api_client.go: HTTP client with cache-first lookup (120s fresh
window, 60-min stale fallback). Cache record shape matches JS so
cross-runtime KV migration round-trips.
- parse_date.go: ICT-anchored date parser. Accepts dd-mm-yyyy,
dd/mm/yyyy, ddmmyyyy; trailing month/year may be omitted (default
to current ICT month/year). Rejects impossible dates (Apr 31, Feb
29 in non-leap, etc.).
- format.go: Today (grouped by league) and Week (grouped by league
-> day) renderers. Major-league filter (LCK/LPL/LEC/LCS/Worlds/
MSI/etc.) keeps replies under Telegram's 4096-char limit. All
user-influenced strings HTML-escaped.
- subscribers.go: Idempotent add/remove/list keyed by chat id.
- handlers.go: 5 commands (`/lolschedule [date]`,
`/lolschedule_today`, `/lolschedule_week`,
`/lolschedule_subscribe`, `/lolschedule_unsubscribe`).
- 22 tests across api-client (cache hit / miss / stale fallback /
hard fail / show filter / non-JSON), parse-date (full and
short formats, defaults, rejections, ICT anchor), format (event
line states, league ordering, week grouping, HTML escape, major
filter), subscribers (idempotent add/remove), handlers (HTML
reply, error path, subscribe/unsubscribe round-trip).
Daily-push cron deferred to Phase 09 (Cloud Scheduler). Subscribers
are still collected so the push lights up the moment the cron infra
lands. Deps doesn't currently expose a *bot.Bot reference; that is
the prerequisite that Phase 09 will solve.
go test -race -count=1 ./... clean (19 packages); golangci-lint clean.
128 lines
3.8 KiB
Go
128 lines
3.8 KiB
Go
package lolschedule
|
||
|
||
import (
|
||
"fmt"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// ictOffset is the ICT (UTC+7) offset. All day boundaries in this module
|
||
// are anchored on ICT.
|
||
const ictOffset = 7 * time.Hour
|
||
|
||
// formatHint is the user-facing usage line appended to parse errors.
|
||
const formatHint = "Use dd-mm-yyyy, dd/mm/yyyy, or ddmmyyyy."
|
||
|
||
// IctLocation is the fixed-offset UTC+7 timezone.
|
||
var IctLocation = time.FixedZone("ICT", int(ictOffset/time.Second))
|
||
|
||
// parseDateResult is the outcome of ParseScheduleDate. Date is the start of
|
||
// the requested ICT day, expressed as a UTC instant.
|
||
type parseDateResult struct {
|
||
OK bool
|
||
Date time.Time
|
||
Error string
|
||
}
|
||
|
||
var digitsOnly = regexp.MustCompile(`^\d+$`)
|
||
|
||
// ictDayStartOf returns the start of the ICT calendar day containing now,
|
||
// expressed as a UTC instant.
|
||
func ictDayStartOf(now time.Time) time.Time {
|
||
ict := now.In(IctLocation)
|
||
dayStart := time.Date(ict.Year(), ict.Month(), ict.Day(), 0, 0, 0, 0, IctLocation)
|
||
return dayStart.UTC()
|
||
}
|
||
|
||
// addDays returns date + days, preserving time-of-day.
|
||
func addDays(date time.Time, days int) time.Time {
|
||
return date.Add(time.Duration(days) * 24 * time.Hour)
|
||
}
|
||
|
||
// splitParts breaks the trimmed input into [dd, mm?, yyyy?] string parts.
|
||
// Mirrors JS splitParts: dash- or slash-separated, or 1/2/4/8-digit unbroken.
|
||
func splitParts(trimmed string) ([]string, string) {
|
||
if strings.ContainsAny(trimmed, "-/") {
|
||
// Replace both delimiters with a single one, then split.
|
||
normalized := strings.ReplaceAll(trimmed, "/", "-")
|
||
parts := strings.Split(normalized, "-")
|
||
if len(parts) < 1 || len(parts) > 3 {
|
||
return nil, fmt.Sprintf(`Invalid date %q. %s`, trimmed, formatHint)
|
||
}
|
||
for _, p := range parts {
|
||
if p == "" || !digitsOnly.MatchString(p) {
|
||
return nil, fmt.Sprintf(`Invalid date %q. %s`, trimmed, formatHint)
|
||
}
|
||
}
|
||
return parts, ""
|
||
}
|
||
|
||
if !digitsOnly.MatchString(trimmed) {
|
||
return nil, fmt.Sprintf(`Invalid date %q. %s`, trimmed, formatHint)
|
||
}
|
||
switch len(trimmed) {
|
||
case 1, 2:
|
||
return []string{trimmed}, ""
|
||
case 4:
|
||
return []string{trimmed[:2], trimmed[2:]}, ""
|
||
case 8:
|
||
return []string{trimmed[:2], trimmed[2:4], trimmed[4:]}, ""
|
||
default:
|
||
return nil, fmt.Sprintf(`Invalid date %q. %s`, trimmed, formatHint)
|
||
}
|
||
}
|
||
|
||
// ParseScheduleDate parses a /lolschedule date argument. Empty input → today.
|
||
// Returns the start of the requested ICT day as a UTC instant.
|
||
func ParseScheduleDate(input string, now time.Time) parseDateResult {
|
||
trimmed := strings.TrimSpace(input)
|
||
if trimmed == "" {
|
||
return parseDateResult{OK: true, Date: ictDayStartOf(now)}
|
||
}
|
||
|
||
parts, errMsg := splitParts(trimmed)
|
||
if errMsg != "" {
|
||
return parseDateResult{Error: errMsg}
|
||
}
|
||
|
||
ictNow := now.In(IctLocation)
|
||
day, _ := strconv.Atoi(parts[0])
|
||
month := int(ictNow.Month())
|
||
year := ictNow.Year()
|
||
if len(parts) >= 2 {
|
||
month, _ = strconv.Atoi(parts[1])
|
||
}
|
||
if len(parts) >= 3 {
|
||
year, _ = strconv.Atoi(parts[2])
|
||
}
|
||
|
||
if day < 1 || day > 31 {
|
||
return parseDateResult{Error: fmt.Sprintf(`Invalid day %q — must be 1–31.`, parts[0])}
|
||
}
|
||
if month < 1 || month > 12 {
|
||
monthStr := ""
|
||
if len(parts) >= 2 {
|
||
monthStr = parts[1]
|
||
}
|
||
return parseDateResult{Error: fmt.Sprintf(`Invalid month %q — must be 1–12.`, monthStr)}
|
||
}
|
||
if year < 1970 || year > 2100 {
|
||
yearStr := ""
|
||
if len(parts) >= 3 {
|
||
yearStr = parts[2]
|
||
}
|
||
return parseDateResult{Error: fmt.Sprintf(`Invalid year %q.`, yearStr)}
|
||
}
|
||
|
||
// Build the ICT-midnight instant. time.Date normalises out-of-range days
|
||
// (e.g. April 31 → May 1) so we verify the round-trip below.
|
||
candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, IctLocation)
|
||
if candidate.Year() != year || int(candidate.Month()) != month || candidate.Day() != day {
|
||
return parseDateResult{Error: fmt.Sprintf(`Invalid date — %d/%d/%d does not exist.`, day, month, year)}
|
||
}
|
||
|
||
return parseDateResult{OK: true, Date: candidate.UTC()}
|
||
}
|