235 lines
5 KiB
Go
235 lines
5 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 {
|
|
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
|
|
})
|
|
|
|
j.purgeResolved(inactive)
|
|
|
|
var newlyInactive []inactiveMember
|
|
for _, member := range inactive {
|
|
if _, alreadyNotified := j.notified[member.Tag]; alreadyNotified {
|
|
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 {
|
|
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, " ")
|
|
}
|