ActivityFetcher/jobs/clan_monitor_job.go
2025-10-14 10:55:39 +02:00

242 lines
5.6 KiB
Go

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
notified map[string]time.Time
}
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),
notified: make(map[string]time.Time),
}, 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 {
log.Printf("clan monitor: no members returned for clan %s", j.clanTag)
return nil
}
log.Printf("clan monitor: fetched %d members for clan %s", len(members), j.clanTag)
var inactive []inactiveMember
for _, member := range members {
if member.LastSeen.IsZero() {
log.Printf("clan monitor: missing lastSeen for %s (%s); skipping", member.Name, member.Tag)
continue
}
inactiveFor := time.Since(member.LastSeen)
if inactiveFor >= offlineThreshold {
inactive = append(inactive, inactiveMember{
MemberDetail: member,
InactiveFor: inactiveFor,
})
}
}
if len(inactive) == 0 {
log.Printf("clan monitor: no members inactive ≥ %s", formatDuration(offlineThreshold))
return nil
}
sort.Slice(inactive, func(i, j int) bool {
return inactive[i].InactiveFor > inactive[j].InactiveFor
})
j.purgeResolved(inactive)
var newlyInactive []inactiveMember
for _, member := range inactive {
if _, alreadyNotified := j.notified[member.Tag]; alreadyNotified {
log.Printf("clan monitor: already notified inactivity for %s (%s); skipping DM", member.Name, member.Tag)
continue
}
newlyInactive = append(newlyInactive, member)
j.notified[member.Tag] = time.Now()
log.Printf("clan monitor: notifying inactivity for %s (%s); offline %s", member.Name, member.Tag, formatDuration(member.InactiveFor))
}
if len(newlyInactive) == 0 {
log.Printf("clan monitor: no new inactive members to notify (all %d already handled)", len(inactive))
return nil
}
messages := buildMessages(newlyInactive)
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 (j *ClanMonitorJob) purgeResolved(inactive []inactiveMember) {
active := make(map[string]struct{}, len(inactive))
for _, member := range inactive {
active[member.Tag] = struct{}{}
}
for tag := range j.notified {
if _, stillInactive := active[tag]; !stillInactive {
delete(j.notified, tag)
}
}
}
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, " ")
}