package jobs import ( "context" "fmt" "log" "os" "sort" "strings" "time" "fetcher/internal/clash" "fetcher/internal/discord" ) const ( clanMonitorInterval = 30 * time.Minute offlineThreshold = 48 * time.Hour maxDiscordMessage = 1900 // leave headroom below Discord's 2000 char limit. ) func init() { job, err := newClanMonitorJobFromEnv() if err != nil { log.Printf("clan monitor job disabled: %v", err) return } Register(job) } // ClanMonitorJob scans clan members and notifies when inactivity exceeds the threshold. type ClanMonitorJob struct { clanTag string recipientID string clashClient *clash.Client discordClient *discord.Client } func newClanMonitorJobFromEnv() (*ClanMonitorJob, error) { clanTag := os.Getenv("COC_CLAN_TAG") if clanTag == "" { return nil, fmt.Errorf("missing COC_CLAN_TAG") } clashToken := os.Getenv("COC_API_TOKEN") if clashToken == "" { return nil, fmt.Errorf("missing COC_API_TOKEN") } discordToken := os.Getenv("DISCORD_BOT_TOKEN") if discordToken == "" { return nil, fmt.Errorf("missing DISCORD_BOT_TOKEN") } recipientID := os.Getenv("DISCORD_RECIPIENT_ID") if recipientID == "" { return nil, fmt.Errorf("missing DISCORD_RECIPIENT_ID") } return &ClanMonitorJob{ clanTag: clanTag, recipientID: recipientID, clashClient: clash.NewClient(clashToken), discordClient: discord.NewClient(discordToken), }, nil } func (j *ClanMonitorJob) Name() string { return "clan_monitor" } func (j *ClanMonitorJob) Interval() time.Duration { return clanMonitorInterval } func (j *ClanMonitorJob) Run(ctx context.Context) error { members, err := j.clashClient.ClanMembers(ctx, j.clanTag) if err != nil { return fmt.Errorf("fetch clan members: %w", err) } if len(members) == 0 { return nil } var inactive []inactiveMember for _, member := range members { if member.LastSeen.IsZero() { continue } inactiveFor := time.Since(member.LastSeen) if inactiveFor >= offlineThreshold { inactive = append(inactive, inactiveMember{ MemberDetail: member, InactiveFor: inactiveFor, }) } } if len(inactive) == 0 { return nil } sort.Slice(inactive, func(i, j int) bool { return inactive[i].InactiveFor > inactive[j].InactiveFor }) messages := buildMessages(inactive) for _, message := range messages { if err := j.discordClient.SendDM(ctx, j.recipientID, message); err != nil { return fmt.Errorf("send discord notification: %w", err) } } return nil } type inactiveMember struct { clash.MemberDetail InactiveFor time.Duration } func buildMessages(members []inactiveMember) []string { header := fmt.Sprintf("Clan members inactive ≥ %s\n", formatDuration(offlineThreshold)) var chunks []string var builder strings.Builder builder.WriteString(header) for _, member := range members { entry := formatMember(member) if builder.Len()+len(entry) > maxDiscordMessage { chunks = append(chunks, builder.String()) builder.Reset() builder.WriteString(header) } builder.WriteString(entry) } if builder.Len() > len(header) { chunks = append(chunks, builder.String()) } return chunks } func formatMember(member inactiveMember) string { lastSeen := member.LastSeen.Format(time.RFC3339) offline := formatDuration(member.InactiveFor) return fmt.Sprintf( "• %s (%s)\n Town Hall: %d | League: %s | Trophies: %d\n Donated: %d | Received: %d\n Last Seen: %s (%s ago)\n\n", member.Name, member.Tag, member.TownHallLevel, emptyFallback(member.League, "Unknown"), member.Trophies, member.Donations, member.DonationsReceived, lastSeen, offline, ) } func emptyFallback(value, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return value } func formatDuration(d time.Duration) string { if d <= 0 { return "0s" } d = d.Round(time.Minute) days := d / (24 * time.Hour) d -= days * 24 * time.Hour hours := d / time.Hour d -= hours * time.Hour minutes := d / time.Minute var parts []string if days > 0 { parts = append(parts, fmt.Sprintf("%dd", days)) } if hours > 0 { parts = append(parts, fmt.Sprintf("%dh", hours)) } if minutes > 0 { parts = append(parts, fmt.Sprintf("%dm", minutes)) } return strings.Join(parts, " ") }