feat(go-services): discord-go — Go port of discord-rare-monitor
Consumes the Python tracker's /ws/live firehose (subscribed to rare+chat), classifies rares common/great, posts embeds + relays allegiance chat. - classify.go: the 74-name common-rares set, extracted verbatim from the Python COMMON_RARES_PATTERN (not hand-transcribed). go test runs at build time; a server-side dump-rares vs the Python regex confirms the sets are IDENTICAL. - poster.go: a `poster` interface with a real discordgo impl (REST sends by channel id; gold/blue embeds, location/time fields, icon attachment) and a dry-run log impl. - ws.go: coder/websocket client to /ws/live with subscribe, ping keepalive, exponential-backoff reconnect; rare/chat dispatch incl. vortex-warning + the MONITOR_CHARACTER filter. - SAFE BY DEFAULT: dry-run unless a token AND DRY_RUN=0 are set, so it can never double-post to production. Deployed via the compose override (discord-rare-monitor-go), running dry-run against the same live firehose. Validated on the server: connects, subscribes, relays a real chat in dry-run; classifier parity 74/74 vs the Python regex. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
426fe025d3
commit
8d4c6ff68f
8 changed files with 593 additions and 0 deletions
166
go-services/discord-go/poster.go
Normal file
166
go-services/discord-go/poster.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue