package partyfinder import ( "database/sql" "fmt" "log" "strings" "time" "github.com/bwmarrin/discordgo" "ffxiv-bot/db" "ffxiv-bot/scraper" ) type PartyFinder struct { sess *discordgo.Session channelID string db *sql.DB scrapeInterval time.Duration emojiIDs map[string]int64 roleEmojiMap map[string][]string gameRoleToDiscordRole map[string]int64 gameContentToDiscordRole map[string]int64 } func New(sess *discordgo.Session, channelID string, db *sql.DB, intervalMin int, emojiIDs map[string]int64, roleEmojiMap map[string][]string, gameRoleToDiscordRole map[string]int64, gameContentToDiscordRole map[string]int64) *PartyFinder { return &PartyFinder{ sess: sess, channelID: channelID, db: db, scrapeInterval: time.Duration(intervalMin) * time.Minute, emojiIDs: emojiIDs, roleEmojiMap: roleEmojiMap, gameRoleToDiscordRole: gameRoleToDiscordRole, gameContentToDiscordRole: gameContentToDiscordRole, } } func (pf *PartyFinder) Run() { log.Printf("[PartyFinder] Starting party finder loop (interval: %s)", pf.scrapeInterval) ticker := time.NewTicker(pf.scrapeInterval) defer ticker.Stop() // Run immediately on start log.Println("[PartyFinder] Running initial scrape") pf.update() for range ticker.C { log.Println("[PartyFinder] Running scheduled scrape") pf.update() } } func (pf *PartyFinder) update() { log.Println("[PartyFinder] Fetching registered host names from database") // Get all registered character names hostNames, err := db.GetHostNames(pf.db) if err != nil { log.Printf("[PartyFinder] Error getting host names: %v", err) return } log.Printf("[PartyFinder] Registered host names (%d): %v", len(hostNames), hostNames) // Fetch parties from xivpf log.Println("[PartyFinder] Scraping xivpf.com/listings") parties, err := scraper.FetchParties() if err != nil { log.Printf("[PartyFinder] Error fetching parties: %v", err) return } log.Printf("[PartyFinder] Fetched %d parties from xivpf", len(parties)) // Log every scraped party for debugging for _, p := range parties { log.Printf("[PartyFinder] Scraped party: ID=%s Host=%s Duty=%s", p.ID, p.HostName, p.Duty) } // Filter to only parties hosted by registered community members var communityParties []scraper.Party for _, p := range parties { if p.MinILvl != 999 && hostNames[p.HostName] { log.Printf("[PartyFinder] MATCH: party ID=%s Host=%s matches registered character", p.ID, p.HostName) communityParties = append(communityParties, p) } } log.Printf("[PartyFinder] Found %d community parties after filtering", len(communityParties)) // Build set of current party IDs and upsert messages partyIDs := make(map[string]bool) for _, p := range communityParties { partyIDs[p.ID] = true content := pf.formatParty(p) existingMsgID, err := db.GetDiscordMsgIDByPartyID(pf.db, p.ID) if err == nil { // Update existing message log.Printf("[PartyFinder] Updating existing message for party %s", p.ID) _, err := pf.sess.ChannelMessageEdit(pf.channelID, existingMsgID, content) if err != nil { log.Printf("[PartyFinder] Error updating message %s: %v", existingMsgID, err) db.DeletePartyMessageByPartyID(pf.db, p.ID) pf.sendMessage(p) } } else { // New party, send new message log.Printf("[PartyFinder] Sending new message for party %s (host: %s)", p.ID, p.HostName) pf.sendMessage(p) } } // Load all tracked messages to find stale ones trackedMsgs, err := db.GetAllPartyMessages(pf.db) if err != nil { log.Printf("[PartyFinder] Error loading party messages: %v", err) return } for _, tm := range trackedMsgs { if !partyIDs[tm.PartyID] { log.Printf("[PartyFinder] Deleting message %s for removed party %s", tm.DiscordMessageID, tm.PartyID) err := pf.sess.ChannelMessageDelete(pf.channelID, tm.DiscordMessageID) if err != nil { log.Printf("[PartyFinder] Error deleting message %s: %v", tm.DiscordMessageID, err) } db.DeletePartyMessage(pf.db, tm.DiscordMessageID) } } log.Printf("[PartyFinder] Sync complete: %d active parties", len(communityParties)) } func (pf *PartyFinder) sendMessage(p scraper.Party) { content := pf.formatParty(p) msg, err := pf.sess.ChannelMessageSend(pf.channelID, content) if err != nil { log.Printf("[PartyFinder] Error sending message for party %s: %v", p.ID, err) return } db.SavePartyMessage(pf.db, msg.ID, p.ID) log.Printf("[PartyFinder] Sent message %s for party %s", msg.ID, p.ID) } func (pf *PartyFinder) formatParty(p scraper.Party) string { var sb strings.Builder // Collect content ping from duty name normalizedDuty := strings.ReplaceAll(p.Duty, "'", "'") var contentPing string if roleID, ok := pf.gameContentToDiscordRole[p.Duty]; ok { contentPing = fmt.Sprintf("<@&%d>", roleID) } else if roleID, ok := pf.gameContentToDiscordRole[normalizedDuty]; ok { contentPing = fmt.Sprintf("<@&%d>", roleID) } // Collect role pings from open slots pingedRoles := make(map[string]bool) for _, slot := range p.Slots { if slot.Filled { continue } roleName := pf.resolveSlotRole(slot) if roleName != "" { pingedRoles[roleName] = true } } // Content ping first if contentPing != "" { sb.WriteString(contentPing + " ") } // Duty name sb.WriteString(fmt.Sprintf("**%s**\n", p.Duty)) // Host hostLine := fmt.Sprintf("*%s @ %s", p.HostName, p.HostServer) if discordID, err := db.GetDiscordIDByCharacter(pf.db, p.HostFirstName, p.HostLastName, p.HostServer); err == nil { hostLine += fmt.Sprintf(" (<@%s>)", discordID) } sb.WriteString(hostLine + "*\n") // Description if p.Description != "" { sb.WriteString(fmt.Sprintf("\n> %s\n", p.Description)) } // Party slots sb.WriteString("\n") for i, slot := range p.Slots { sb.WriteString(scraper.FormatSlot(slot, pf.emojiIDs, pf.roleEmojiMap)) if i < len(p.Slots)-1 { sb.WriteString(" ") } } // Role pings at end for _, roleName := range pf.sortedRoles(pingedRoles) { if roleID, ok := pf.gameRoleToDiscordRole[roleName]; ok { sb.WriteString(fmt.Sprintf(" <@&%d>", roleID)) } } return sb.String() } func (pf *PartyFinder) resolveSlotRole(slot scraper.Slot) string { // For multi-job open slots, check if all jobs belong to one sub-role if len(slot.Jobs) > 1 { for role, jobs := range pf.roleEmojiMap { jobSet := make(map[string]bool, len(jobs)) for _, j := range jobs { jobSet[j] = true } allMatch := true for _, j := range slot.Jobs { if !jobSet[j] { allMatch = false break } } if allMatch { return role } } } return slot.Role } func (pf *PartyFinder) sortedRoles(pingedRoles map[string]bool) []string { // Return role names in a consistent order for readability order := []string{"tank", "healer", "melee", "caster", "physrange", "barrier", "regen", "dps"} var result []string for _, r := range order { if pingedRoles[r] { result = append(result, r) } } return result } func (pf *PartyFinder) isContentPing(roleName string) bool { return strings.HasPrefix(roleName, "<@&") && strings.HasSuffix(roleName, ">") }