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
14
go-services/discord-go/Dockerfile
Normal file
14
go-services/discord-go/Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Multi-stage build for the Go discord-rare-monitor port. The unit test (rare
|
||||||
|
# classification) runs at build time, so a classifier regression fails the build.
|
||||||
|
FROM golang:1.25-bookworm AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY . .
|
||||||
|
RUN go mod tidy
|
||||||
|
RUN go test ./...
|
||||||
|
ARG BUILD_VERSION=dev
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w" -o /out/discord-go .
|
||||||
|
|
||||||
|
# distroless/static includes CA certificates (needed for Discord's HTTPS REST API).
|
||||||
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
COPY --from=build /out/discord-go /discord-go
|
||||||
|
ENTRYPOINT ["/discord-go"]
|
||||||
93
go-services/discord-go/classify.go
Normal file
93
go-services/discord-go/classify.go
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
// Rare classification — a faithful port of discord_rare_monitor.py's
|
||||||
|
// COMMON_RARES_PATTERN (an anchored exact-match regex of common-rare names).
|
||||||
|
// Because the regex is fully anchored with no wildcards, an exact-match set is
|
||||||
|
// equivalent. This list was extracted verbatim from the Python source, not
|
||||||
|
// hand-transcribed. classify_test.go asserts every entry maps to "common".
|
||||||
|
//
|
||||||
|
// classify returns "common" for an exact match, "great" otherwise — identical
|
||||||
|
// to classify_rare().
|
||||||
|
func classify(rareName string) string {
|
||||||
|
if commonRares[rareName] {
|
||||||
|
return "common"
|
||||||
|
}
|
||||||
|
return "great"
|
||||||
|
}
|
||||||
|
|
||||||
|
var commonRares = map[string]bool{
|
||||||
|
"Alchemist's Crystal": true,
|
||||||
|
"Scholar's Crystal": true,
|
||||||
|
"Smithy's Crystal": true,
|
||||||
|
"Hunter's Crystal": true,
|
||||||
|
"Observer's Crystal": true,
|
||||||
|
"Thorsten's Crystal": true,
|
||||||
|
"Elysa's Crystal": true,
|
||||||
|
"Chef's Crystal": true,
|
||||||
|
"Enchanter's Crystal": true,
|
||||||
|
"Oswald's Crystal": true,
|
||||||
|
"Deceiver's Crystal": true,
|
||||||
|
"Fletcher's Crystal": true,
|
||||||
|
"Physician's Crystal": true,
|
||||||
|
"Artificer's Crystal": true,
|
||||||
|
"Tinker's Crystal": true,
|
||||||
|
"Vaulter's Crystal": true,
|
||||||
|
"Monarch's Crystal": true,
|
||||||
|
"Life Giver's Crystal": true,
|
||||||
|
"Thief's Crystal": true,
|
||||||
|
"Adherent's Crystal": true,
|
||||||
|
"Resister's Crystal": true,
|
||||||
|
"Imbuer's Crystal": true,
|
||||||
|
"Converter's Crystal": true,
|
||||||
|
"Evader's Crystal": true,
|
||||||
|
"Dodger's Crystal": true,
|
||||||
|
"Zefir's Crystal": true,
|
||||||
|
"Ben Ten's Crystal": true,
|
||||||
|
"Corruptor's Crystal": true,
|
||||||
|
"Artist's Crystal": true,
|
||||||
|
"T'ing's Crystal": true,
|
||||||
|
"Warrior's Crystal": true,
|
||||||
|
"Brawler's Crystal": true,
|
||||||
|
"Hieromancer's Crystal": true,
|
||||||
|
"Rogue's Crystal": true,
|
||||||
|
"Berzerker's Crystal": true,
|
||||||
|
"Knight's Crystal": true,
|
||||||
|
"Lugian's Pearl": true,
|
||||||
|
"Ursuin's Pearl": true,
|
||||||
|
"Wayfarer's Pearl": true,
|
||||||
|
"Sprinter's Pearl": true,
|
||||||
|
"Magus's Pearl": true,
|
||||||
|
"Lich's Pearl": true,
|
||||||
|
"Warrior's Jewel": true,
|
||||||
|
"Melee's Jewel": true,
|
||||||
|
"Mage's Jewel": true,
|
||||||
|
"Duelist's Jewel": true,
|
||||||
|
"Archer's Jewel": true,
|
||||||
|
"Tusker's Jewel": true,
|
||||||
|
"Olthoi's Jewel": true,
|
||||||
|
"Inferno's Jewel": true,
|
||||||
|
"Gelid's Jewel": true,
|
||||||
|
"Astyrrian's Jewel": true,
|
||||||
|
"Executor's Jewel": true,
|
||||||
|
"Pearl of Blood Drinking": true,
|
||||||
|
"Pearl of Heart Seeking": true,
|
||||||
|
"Pearl of Defending": true,
|
||||||
|
"Pearl of Swift Killing": true,
|
||||||
|
"Pearl of Spirit Drinking": true,
|
||||||
|
"Pearl of Hermetic Linking": true,
|
||||||
|
"Pearl of Blade Baning": true,
|
||||||
|
"Pearl of Pierce Baning": true,
|
||||||
|
"Pearl of Bludgeon Baning": true,
|
||||||
|
"Pearl of Acid Baning": true,
|
||||||
|
"Pearl of Flame Baning": true,
|
||||||
|
"Pearl of Frost Baning": true,
|
||||||
|
"Pearl of Lightning Baning": true,
|
||||||
|
"Pearl of Impenetrability": true,
|
||||||
|
"Refreshing Elixir": true,
|
||||||
|
"Invigorating Elixir": true,
|
||||||
|
"Miraculous Elixir": true,
|
||||||
|
"Medicated Health Kit": true,
|
||||||
|
"Medicated Stamina Kit": true,
|
||||||
|
"Medicated Mana Kit": true,
|
||||||
|
"Casino Exquisite Keyring": true,
|
||||||
|
}
|
||||||
38
go-services/discord-go/classify_test.go
Normal file
38
go-services/discord-go/classify_test.go
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Every name in the common-rares set must classify as "common".
|
||||||
|
func TestClassifyCommon(t *testing.T) {
|
||||||
|
if len(commonRares) != 74 {
|
||||||
|
t.Fatalf("expected 74 common rares, got %d", len(commonRares))
|
||||||
|
}
|
||||||
|
for name := range commonRares {
|
||||||
|
if got := classify(name); got != "common" {
|
||||||
|
t.Errorf("classify(%q) = %q, want common", name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Names not in the set (including near-misses) must classify as "great".
|
||||||
|
func TestClassifyGreat(t *testing.T) {
|
||||||
|
greats := []string{
|
||||||
|
"Shimmering Skeleton Key",
|
||||||
|
"Star of Tukal",
|
||||||
|
"Hieroglyph of the Bludgeoner",
|
||||||
|
"Infinite Phial of Pyreal Flux",
|
||||||
|
"Foolproof Hooks",
|
||||||
|
"Staff of All Aphus",
|
||||||
|
"Count Renari's Equctioneer",
|
||||||
|
"Gelidite Long Sword",
|
||||||
|
"Pearl of Blade Baning ", // trailing space — not an exact match
|
||||||
|
"alchemist's crystal", // wrong case — not an exact match
|
||||||
|
"Alchemist's Crystals", // plural — not an exact match
|
||||||
|
"",
|
||||||
|
}
|
||||||
|
for _, name := range greats {
|
||||||
|
if got := classify(name); got != "great" {
|
||||||
|
t.Errorf("classify(%q) = %q, want great", name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
go-services/discord-go/go.mod
Normal file
3
go-services/discord-go/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module git.snakedesert.se/SawatoMosswartsEnjoyersClub/MosswartOverlord/go-services/discord-go
|
||||||
|
|
||||||
|
go 1.25
|
||||||
90
go-services/discord-go/main.go
Normal file
90
go-services/discord-go/main.go
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
// Command discord-go is a Go port of discord-rare-monitor: it consumes the
|
||||||
|
// tracker's /ws/live firehose (subscribed to rare+chat), classifies rares
|
||||||
|
// common/great, posts embeds to Discord, and relays allegiance chat.
|
||||||
|
//
|
||||||
|
// SAFETY: it runs in dry-run (log only, no Discord posts) by default. Going live
|
||||||
|
// requires BOTH a bot token AND DRY_RUN=0 — so it can never accidentally
|
||||||
|
// double-post to the production channels during the parallel run. For a parallel
|
||||||
|
// test, set a TEST token + TEST channel IDs + DRY_RUN=0.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sort"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// `discord-go dump-rares` prints the common-rares set (for parity diffing
|
||||||
|
// against the Python regex). No network, no token.
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "dump-rares" {
|
||||||
|
names := make([]string, 0, len(commonRares))
|
||||||
|
for n := range commonRares {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
for _, n := range names {
|
||||||
|
fmt.Println(n)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
|
token := os.Getenv("DISCORD_RARE_BOT_TOKEN")
|
||||||
|
// Dry-run unless a token is present AND DRY_RUN is explicitly "0".
|
||||||
|
dryRun := token == "" || os.Getenv("DRY_RUN") != "0"
|
||||||
|
|
||||||
|
wsURL := envOr("DERETH_TRACKER_WS_URL", "ws://dereth-tracker:8765/ws/live")
|
||||||
|
monitorChar := envOr("MONITOR_CHARACTER", "Dunking Rares")
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
var out poster
|
||||||
|
if dryRun {
|
||||||
|
reason := "no DISCORD_RARE_BOT_TOKEN"
|
||||||
|
if token != "" {
|
||||||
|
reason = "DRY_RUN != 0"
|
||||||
|
}
|
||||||
|
logger.Info("starting in DRY-RUN — classifying but NOT posting to Discord", "reason", reason, "ws", wsURL, "monitor", monitorChar)
|
||||||
|
out = &logPoster{log: logger}
|
||||||
|
} else {
|
||||||
|
dg, err := discordgo.New("Bot " + token)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("discord session init failed", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// REST-only: we send by channel ID, so no gateway Open()/intents needed.
|
||||||
|
logger.Info("starting LIVE — posting to Discord", "ws", wsURL, "monitor", monitorChar)
|
||||||
|
out = &discordPoster{
|
||||||
|
dg: dg,
|
||||||
|
common: envOr("COMMON_RARE_CHANNEL_ID", "1355328792184226014"),
|
||||||
|
great: envOr("GREAT_RARE_CHANNEL_ID", "1353676584334131211"),
|
||||||
|
aclog: envOr("ACLOG_CHANNEL_ID", "1349649482786275328"),
|
||||||
|
sawato: envOr("SAWATOLIFE_CHANNEL_ID", "1387323032271327423"),
|
||||||
|
iconsDir: envOr("ICONS_DIR", "icons"),
|
||||||
|
log: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b := &bot{wsURL: wsURL, monitorChar: monitorChar, out: out, log: logger}
|
||||||
|
go b.run(ctx)
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
logger.Info("shutdown signal received")
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
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()
|
||||||
|
}
|
||||||
159
go-services/discord-go/ws.go
Normal file
159
go-services/discord-go/ws.go
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bot consumes the tracker's /ws/live firehose (subscribed to rare+chat) and
|
||||||
|
// routes events to a poster. It reconnects with exponential backoff, mirroring
|
||||||
|
// monitor_websocket().
|
||||||
|
type bot struct {
|
||||||
|
wsURL string
|
||||||
|
monitorChar string
|
||||||
|
out poster
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bot) run(ctx context.Context) {
|
||||||
|
backoff := time.Second
|
||||||
|
const maxBackoff = 60 * time.Second
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
err := b.connectAndConsume(ctx)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
b.log.Warn("ws disconnected; reconnecting", "err", err, "backoff", backoff.String())
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
backoff *= 2
|
||||||
|
if backoff > maxBackoff {
|
||||||
|
backoff = maxBackoff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bot) connectAndConsume(ctx context.Context) error {
|
||||||
|
b.log.Info("connecting to /ws/live", "url", b.wsURL)
|
||||||
|
c, _, err := websocket.Dial(ctx, b.wsURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer c.CloseNow()
|
||||||
|
c.SetReadLimit(8 << 20) // payloads (nearby_objects etc.) can be large; we only read rare/chat but the socket carries all
|
||||||
|
|
||||||
|
// Subscribe to just rare + chat (server-side filtering), like the Python bot.
|
||||||
|
sub, _ := json.Marshal(map[string]any{"type": "subscribe", "message_types": []string{"rare", "chat"}})
|
||||||
|
if err := c.Write(ctx, websocket.MessageText, sub); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.log.Info("subscribed", "message_types", []string{"rare", "chat"})
|
||||||
|
b.out.postStatus("🔗 (go) WebSocket connection established")
|
||||||
|
|
||||||
|
// Keepalive pings, like ping_interval=20.
|
||||||
|
pingCtx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(20 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-pingCtx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
pc, cc := context.WithTimeout(pingCtx, 10*time.Second)
|
||||||
|
_ = c.Ping(pc)
|
||||||
|
cc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, data, err := c.Read(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.handleMessage(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bot) handleMessage(raw []byte) {
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &data); err != nil {
|
||||||
|
return // ignore invalid JSON, like the Python bot
|
||||||
|
}
|
||||||
|
switch asString(data["type"]) {
|
||||||
|
case "rare":
|
||||||
|
b.handleRare(data)
|
||||||
|
case "chat":
|
||||||
|
b.handleChat(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bot) handleRare(data map[string]any) {
|
||||||
|
ev := rareEvent{
|
||||||
|
Name: asStringOr(data["name"], "Unknown Rare"),
|
||||||
|
CharacterName: asStringOr(data["character_name"], "Unknown Character"),
|
||||||
|
Timestamp: asString(data["timestamp"]),
|
||||||
|
EW: asFloatPtr(data["ew"]),
|
||||||
|
NS: asFloatPtr(data["ns"]),
|
||||||
|
Z: asFloatPtr(data["z"]),
|
||||||
|
}
|
||||||
|
tier := classify(ev.Name)
|
||||||
|
b.log.Info("rare", "name", ev.Name, "character", ev.CharacterName, "tier", tier)
|
||||||
|
b.out.postRare(ev, tier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bot) handleChat(data map[string]any) {
|
||||||
|
charName := asString(data["character_name"])
|
||||||
|
text := asString(data["text"])
|
||||||
|
if charName != b.monitorChar {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "m in whirlwind of vortexes") {
|
||||||
|
b.out.postVortex(parseAllegianceSpeaker(text), text, asString(data["timestamp"]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.out.postChat(charName, text, asString(data["timestamp"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAllegianceSpeaker extracts <name> from "[Allegiance] <name> says, ...".
|
||||||
|
func parseAllegianceSpeaker(text string) string {
|
||||||
|
const prefix = "[Allegiance] "
|
||||||
|
if i := strings.Index(text, prefix); i >= 0 {
|
||||||
|
rest := text[i+len(prefix):]
|
||||||
|
if j := strings.Index(rest, " says,"); j >= 0 {
|
||||||
|
return rest[:j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func asString(v any) string {
|
||||||
|
s, _ := v.(string)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func asStringOr(v any, def string) string {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func asFloatPtr(v any) *float64 {
|
||||||
|
if f, ok := v.(float64); ok {
|
||||||
|
return &f
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -42,3 +42,33 @@ services:
|
||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
|
# Go port of discord-rare-monitor. Consumes the SAME Python /ws/live firehose
|
||||||
|
# as the live Python bot. DRY-RUN by default (logs classifications, posts
|
||||||
|
# nothing) so it can't double-post. To parallel-test for real, set a TEST
|
||||||
|
# DISCORD_RARE_BOT_TOKEN + TEST channel IDs + DRY_RUN=0 here.
|
||||||
|
discord-rare-monitor-go:
|
||||||
|
build:
|
||||||
|
context: ./go-services/discord-go
|
||||||
|
args:
|
||||||
|
BUILD_VERSION: ${BUILD_VERSION:-dev}
|
||||||
|
container_name: discord-rare-monitor-go
|
||||||
|
environment:
|
||||||
|
DERETH_TRACKER_WS_URL: "ws://dereth-tracker:8765/ws/live"
|
||||||
|
MONITOR_CHARACTER: "Dunking Rares"
|
||||||
|
ICONS_DIR: "/icons"
|
||||||
|
LOG_LEVEL: "INFO"
|
||||||
|
# DISCORD_RARE_BOT_TOKEN: "" # set a TEST token to go live
|
||||||
|
# DRY_RUN: "0" # required (with a token) to actually post
|
||||||
|
# COMMON_RARE_CHANNEL_ID / GREAT_RARE_CHANNEL_ID / SAWATOLIFE_CHANNEL_ID /
|
||||||
|
# ACLOG_CHANNEL_ID: set TEST channels before going live
|
||||||
|
volumes:
|
||||||
|
- ./discord-rare-monitor/icons:/icons:ro
|
||||||
|
depends_on:
|
||||||
|
- dereth-tracker
|
||||||
|
restart: unless-stopped
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue