Files
miti99bot/internal/modules/lolschedule/parse_date.go
T
tiennm99 6fa01ba5f1 feat(modules): port lolschedule (5 commands; daily-push cron deferred)
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.
2026-05-09 17:14:01 +07:00

128 lines
3.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 131.`, 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 112.`, 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()}
}