package main import ( "fmt" "log/slog" "os" "path/filepath" "strings" "time" "github.com/bwmarrin/discordgo" ) // Discord embed colors, matching discord.py's Color.gold()/Color.blue(). const ( colorGold = 0xf1c40f colorBlue = 0x3498db colorRed = 0xe74c3c ) type rareEvent struct { Name string CharacterName string Timestamp string EW, NS, Z *float64 } // poster abstracts where messages go: a real Discord session, or a dry-run // logger used for parallel validation without a bot token / live channels. type poster interface { postRare(ev rareEvent, tier string) postChat(charName, text, ts string) postVortex(speaker, text, ts string) postStatus(text string) } // ---- dry-run (log-only) ---- type logPoster struct{ log *slog.Logger } func (p *logPoster) postRare(ev rareEvent, tier string) { p.log.Info("DRY-RUN would post rare", "tier", tier, "channel", tier, "name", ev.Name, "character", ev.CharacterName) } func (p *logPoster) postChat(charName, text, ts string) { p.log.Info("DRY-RUN would relay chat", "character", charName, "text", text) } func (p *logPoster) postVortex(speaker, text, ts string) { p.log.Warn("DRY-RUN would post vortex warning", "speaker", speaker, "text", text) } func (p *logPoster) postStatus(text string) { p.log.Info("DRY-RUN would post status", "text", text) } // ---- real Discord ---- type discordPoster struct { dg *discordgo.Session common string great string aclog string sawato string iconsDir string log *slog.Logger } func (p *discordPoster) postRare(ev rareEvent, tier string) { embed := buildRareEmbed(ev, tier) channel := p.common if tier == "great" { channel = p.great } if iconPath := p.iconPath(ev.Name); iconPath != "" { if f, err := os.Open(iconPath); err == nil { defer f.Close() fn := filepath.Base(iconPath) embed.Image = &discordgo.MessageEmbedImage{URL: "attachment://" + fn} if _, err := p.dg.ChannelMessageSendComplex(channel, &discordgo.MessageSend{ Embed: embed, Files: []*discordgo.File{{Name: fn, Reader: f}}, }); err != nil { p.log.Error("send rare embed (with icon) failed", "err", err, "channel", channel) } return } } if _, err := p.dg.ChannelMessageSendEmbed(channel, embed); err != nil { p.log.Error("send rare embed failed", "err", err, "channel", channel) } } func (p *discordPoster) postChat(charName, text, ts string) { t := parseTime(ts) cleaned := strings.TrimPrefix(text, "Dunking Rares: ") msg := fmt.Sprintf("`%s` %s", t.Format("15:04:05"), cleaned) if _, err := p.dg.ChannelMessageSend(p.sawato, msg); err != nil { p.log.Error("send chat failed", "err", err) } } func (p *discordPoster) postVortex(speaker, text, ts string) { embed := &discordgo.MessageEmbed{ Title: "🌪️ VORTEX WARNING", Description: fmt.Sprintf("**%s**: %s", speaker, text), Color: colorRed, Timestamp: parseTime(ts).Format(time.RFC3339), } if _, err := p.dg.ChannelMessageSendEmbed(p.aclog, embed); err != nil { p.log.Error("send vortex failed", "err", err) } } func (p *discordPoster) postStatus(text string) { if _, err := p.dg.ChannelMessageSend(p.aclog, text); err != nil { p.log.Error("send status failed", "err", err) } } func (p *discordPoster) iconPath(rareName string) string { if p.iconsDir == "" { return "" } fn := strings.NewReplacer("'", "", " ", "_", "-", "_").Replace(rareName) + "_Icon.png" path := filepath.Join(p.iconsDir, fn) if _, err := os.Stat(path); err == nil { return path } return "" } // buildRareEmbed mirrors post_rare_to_discord's embed. func buildRareEmbed(ev rareEvent, tier string) *discordgo.MessageEmbed { title, color := "🔸 Common Rare Discovery", colorBlue if tier == "great" { title, color = "💎 Great Rare Discovery!", colorGold } t := parseTime(ev.Timestamp) embed := &discordgo.MessageEmbed{ Title: title, Description: fmt.Sprintf("**%s** has discovered the **%s**!", ev.CharacterName, ev.Name), Color: color, Timestamp: t.Format(time.RFC3339), } if ev.EW != nil && ev.NS != nil { loc := fmt.Sprintf("%.1fE, %.1fN", *ev.EW, *ev.NS) if ev.Z != nil { loc += fmt.Sprintf(", %.1fZ", *ev.Z) } embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{Name: "📍 Location", Value: loc, Inline: true}) } embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{ Name: "⏰ Time", Value: t.UTC().Format("15:04:05") + " UTC", Inline: true, }) return embed } // parseTime accepts the plugin's ISO8601 (with or without 'Z'); falls back to now. func parseTime(ts string) time.Time { if ts != "" { for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.999999", "2006-01-02T15:04:05"} { if t, err := time.Parse(layout, strings.Replace(ts, "Z", "+00:00", 1)); err == nil { return t } } } return time.Now().UTC() }