diff --git a/README.md b/README.md index 5508102..fcc354a 100644 --- a/README.md +++ b/README.md @@ -341,3 +341,32 @@ http://127.0.0.1:8080/?token=ChangeMePlease ``` Für Docker sollte `DASHBOARD_ADDR=0.0.0.0:8080` gesetzt werden. Das Dashboard ist als internes Admin-/Team-Werkzeug gedacht. Stelle es nicht ungeschützt öffentlich ins Internet. Nutze mindestens `DASHBOARD_TOKEN`; für produktiven Betrieb idealerweise zusätzlich Reverse Proxy mit HTTPS und Zugriffsbeschränkung. + +## Webinterface: Rich Presence & Session-Einladung + +Im Dashboard gibt es jetzt den Bereich **Rich Presence & Session-Einladung**. Dort können Admins über das Webinterface konfigurieren: + +- Bot-Status: online, idle, dnd, invisible +- Activity-Typ: spielt, schaut, hört, tritt an, custom +- Name, State, Details +- Start- und Endzeit +- Large-/Small-Image-Asset-Keys und Texte +- Discord-Zielchannel-ID +- Post-Channel-ID für einen Session-Invite +- Button-Label, Post-Titel und Post-Text + +Wichtig: Discord erlaubt Bot-Activities offiziell nur eingeschränkt. Die Discord-Dokumentation sagt, dass Bot-User nur `name`, `state`, `type` und `url` setzen können. Deshalb speichert das Dashboard zwar Details, Assets und Timestamps und sendet sie mit, aber Discord-Clients können diese Felder ignorieren. Für den eigentlichen Einladungszweck erzeugt der Button **Session-Invite posten** zuverlässig einen Discord-Post mit Link-Button zum konfigurierten Channel. + +Der Channel-Link wird aus `DISCORD_GUILD_ID` und der im Webinterface eingetragenen Zielchannel-ID erzeugt: + +```text +https://discord.com/channels// +``` + +Der Bot benötigt im Invite-Post-Channel: + +```text +View Channel +Send Messages +Embed Links +``` diff --git a/db.go b/db.go index 03e2e3c..aa67613 100644 --- a/db.go +++ b/db.go @@ -106,6 +106,9 @@ CREATE INDEX IF NOT EXISTS idx_order_events_order ON order_events(order_id); if err := s.createTradingSchema(); err != nil { return err } + if err := s.createAppSettingsSchema(); err != nil { + return err + } if _, err := s.db.Exec(`INSERT OR IGNORE INTO storage_locations(name, normalized_name, created_by_id, created_by_name, created_at, updated_at) SELECT DISTINCT TRIM(location), lower(TRIM(location)), 'migration', 'migration', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP FROM inventory @@ -847,3 +850,25 @@ func (s *Store) SummarizeTradingByUser(month, year int) ([]TradingTraderSummary, }) return out, nil } + +func (s *Store) createAppSettingsSchema() error { + _, err := s.db.Exec(` +CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '', + updated_at DATETIME NOT NULL +);`) + return err +} + +func (s *Store) SetSetting(key, value string) error { + _, err := s.db.Exec(`INSERT INTO app_settings(key, value, updated_at) VALUES(?, ?, ?) +ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`, key, value, time.Now().UTC()) + return err +} + +func (s *Store) GetSetting(key string) (string, error) { + var value string + err := s.db.QueryRow(`SELECT value FROM app_settings WHERE key=?`, key).Scan(&value) + return value, err +} diff --git a/main.go b/main.go index 65e5c61..11e8438 100644 --- a/main.go +++ b/main.go @@ -23,7 +23,8 @@ func main() { if err := bot.Start(); err != nil { log.Fatal(err) } - dashboard := StartDashboard(cfg, store) + bot.ApplySavedRichPresence() + dashboard := StartDashboard(cfg, store, bot) log.Println("Bot läuft. Beenden mit Ctrl+C.") stop := make(chan os.Signal, 1) signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) diff --git a/models.go b/models.go index b591933..232510c 100644 --- a/models.go +++ b/models.go @@ -148,3 +148,25 @@ type TradingPurposeSummary struct { SCU float64 Runs int } + +type RichPresenceConfig struct { + Enabled bool + Status string + ActivityType string + Name string + State string + Details string + StartTimestamp int64 + EndTimestamp int64 + LargeImage string + LargeText string + SmallImage string + SmallText string + InviteChannelID string + InvitePostChannelID string + InviteButtonLabel string + InvitePostTitle string + InvitePostText string + LastInviteMessageID string + LastInviteChannelID string +} diff --git a/rich_presence.go b/rich_presence.go new file mode 100644 index 0000000..38d37b9 --- /dev/null +++ b/rich_presence.go @@ -0,0 +1,192 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "log" + "strings" + "time" + + "github.com/bwmarrin/discordgo" +) + +const richPresenceSettingKey = "rich_presence_config" + +func defaultRichPresenceConfig() RichPresenceConfig { + return RichPresenceConfig{ + Enabled: false, + Status: "online", + ActivityType: "watching", + Name: "SF Trading", + State: "Gemeinsame Session", + Details: "Bereit für Orga-Aktivitäten", + InviteButtonLabel: "Channel beitreten", + InvitePostTitle: "Gemeinsame Session", + InvitePostText: "Tritt dem Discord-Channel bei und schließe dich der Session an.", + InviteChannelID: "", + InvitePostChannelID: "", + } +} + +func (s *Store) GetRichPresenceConfig() (RichPresenceConfig, error) { + cfg := defaultRichPresenceConfig() + raw, err := s.GetSetting(richPresenceSettingKey) + if err != nil { + if err == sql.ErrNoRows { + return cfg, nil + } + return cfg, err + } + if strings.TrimSpace(raw) == "" { + return cfg, nil + } + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return cfg, err + } + if cfg.Status == "" { + cfg.Status = "online" + } + if cfg.ActivityType == "" { + cfg.ActivityType = "watching" + } + if cfg.InviteButtonLabel == "" { + cfg.InviteButtonLabel = "Channel beitreten" + } + if cfg.InvitePostTitle == "" { + cfg.InvitePostTitle = "Gemeinsame Session" + } + return cfg, nil +} + +func (s *Store) SaveRichPresenceConfig(cfg RichPresenceConfig) error { + b, err := json.Marshal(cfg) + if err != nil { + return err + } + return s.SetSetting(richPresenceSettingKey, string(b)) +} + +func (b *Bot) ApplySavedRichPresence() { + cfg, err := b.store.GetRichPresenceConfig() + if err != nil { + log.Println("rich presence config load failed:", err) + return + } + if err := b.SetRichPresence(cfg); err != nil { + log.Println("rich presence apply failed:", err) + } +} + +func (b *Bot) SetRichPresence(cfg RichPresenceConfig) error { + if b == nil || b.dg == nil { + return fmt.Errorf("bot session nicht verfügbar") + } + if !cfg.Enabled { + return b.dg.UpdateStatusComplex(discordgo.UpdateStatusData{Status: cfg.Status, Activities: []*discordgo.Activity{}}) + } + activity := &discordgo.Activity{ + Name: firstNonEmpty(cfg.Name, "SF Trading"), + Type: activityTypeFromString(cfg.ActivityType), + State: strings.TrimSpace(cfg.State), + Details: strings.TrimSpace(cfg.Details), + Assets: discordgo.Assets{ + LargeImageID: strings.TrimSpace(cfg.LargeImage), + LargeText: strings.TrimSpace(cfg.LargeText), + SmallImageID: strings.TrimSpace(cfg.SmallImage), + SmallText: strings.TrimSpace(cfg.SmallText), + }, + Timestamps: discordgo.TimeStamps{StartTimestamp: cfg.StartTimestamp, EndTimestamp: cfg.EndTimestamp}, + } + // Discord beschränkt Bot-Presences offiziell auf name/state/type/url. Details, Assets und Timestamps + // werden mitgesendet, können clientseitig aber ignoriert werden. Der Session-Invite-Post enthält + // deshalb den zuverlässigen Button-Link. + return b.dg.UpdateStatusComplex(discordgo.UpdateStatusData{Status: firstNonEmpty(cfg.Status, "online"), Activities: []*discordgo.Activity{activity}}) +} + +func (b *Bot) SendRichPresenceInvite(cfg RichPresenceConfig) (*discordgo.Message, error) { + if strings.TrimSpace(cfg.InvitePostChannelID) == "" { + return nil, fmt.Errorf("Invite-Post-Channel fehlt") + } + link := channelLink(b.cfg.GuildID, cfg.InviteChannelID) + if link == "" { + return nil, fmt.Errorf("Invite-Channel fehlt") + } + buttonLabel := firstNonEmpty(cfg.InviteButtonLabel, "Channel beitreten") + emb := &discordgo.MessageEmbed{ + Title: firstNonEmpty(cfg.InvitePostTitle, "Gemeinsame Session"), + Description: firstNonEmpty(cfg.InvitePostText, "Tritt dem Discord-Channel bei und schließe dich der Session an."), + Color: 0x38bdf8, + Timestamp: time.Now().Format(time.RFC3339), + Fields: []*discordgo.MessageEmbedField{ + {Name: "Channel", Value: fmt.Sprintf("<#%s>", cfg.InviteChannelID), Inline: true}, + {Name: "Status", Value: firstNonEmpty(cfg.State, cfg.Details, cfg.Name, "Session aktiv"), Inline: true}, + }, + } + msg, err := b.dg.ChannelMessageSendComplex(cfg.InvitePostChannelID, &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{emb}, + Components: []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{discordgo.Button{Label: buttonLabel, Style: discordgo.LinkButton, URL: link}}}}, + }) + if err != nil { + return nil, err + } + cfg.LastInviteChannelID = msg.ChannelID + cfg.LastInviteMessageID = msg.ID + _ = b.store.SaveRichPresenceConfig(cfg) + return msg, nil +} + +func activityTypeFromString(v string) discordgo.ActivityType { + switch strings.ToLower(strings.TrimSpace(v)) { + case "playing", "game", "spielt": + return discordgo.ActivityTypeGame + case "streaming": + return discordgo.ActivityTypeStreaming + case "listening", "hört": + return discordgo.ActivityTypeListening + case "competing": + return discordgo.ActivityTypeCompeting + case "custom": + return discordgo.ActivityTypeCustom + default: + return discordgo.ActivityTypeWatching + } +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +func channelLink(guildID, channelID string) string { + guildID = strings.TrimSpace(guildID) + channelID = strings.TrimSpace(channelID) + if guildID == "" || channelID == "" { + return "" + } + return fmt.Sprintf("https://discord.com/channels/%s/%s", guildID, channelID) +} + +func unixMillisFromDatetimeLocal(v string) int64 { + v = strings.TrimSpace(v) + if v == "" { + return 0 + } + for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05", time.RFC3339} { + if t, err := time.ParseInLocation(layout, v, time.Local); err == nil { + return t.UnixMilli() + } + } + return 0 +} + +func datetimeLocalFromUnixMillis(ms int64) string { + if ms <= 0 { + return "" + } + return time.UnixMilli(ms).Local().Format("2006-01-02T15:04") +} diff --git a/web_dashboard.go b/web_dashboard.go index 345bf2c..c6322a6 100644 --- a/web_dashboard.go +++ b/web_dashboard.go @@ -11,6 +11,10 @@ import ( ) type dashboardData struct { + Presence RichPresenceConfig + PresenceStart string + PresenceEnd string + PresenceNotice string Now time.Time Month int Year int @@ -24,13 +28,15 @@ type dashboardData struct { Error string } -func StartDashboard(cfg Config, store *Store) *http.Server { +func StartDashboard(cfg Config, store *Store, bot *Bot) *http.Server { if strings.TrimSpace(cfg.DashboardAddr) == "" { return nil } mux := http.NewServeMux() - h := &dashboardHandler{cfg: cfg, store: store} + h := &dashboardHandler{cfg: cfg, store: store, bot: bot} mux.HandleFunc("/", h.index) + mux.HandleFunc("/presence", h.presence) + mux.HandleFunc("/presence/invite", h.presenceInvite) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) }) srv := &http.Server{Addr: cfg.DashboardAddr, Handler: mux} go func() { @@ -45,6 +51,7 @@ func StartDashboard(cfg Config, store *Store) *http.Server { type dashboardHandler struct { cfg Config store *Store + bot *Bot } func (h *dashboardHandler) authorized(r *http.Request) bool { @@ -69,6 +76,11 @@ func (h *dashboardHandler) index(w http.ResponseWriter, r *http.Request) { month := intFromQuery(r, "monat", int(now.Month())) year := intFromQuery(r, "jahr", now.Year()) data := dashboardData{Now: now, Month: month, Year: year, Token: r.URL.Query().Get("token"), TradingPayee: h.cfg.TradingPayoutRecipient} + if rp, err := h.store.GetRichPresenceConfig(); err == nil { + data.Presence = rp + data.PresenceStart = datetimeLocalFromUnixMillis(rp.StartTimestamp) + data.PresenceEnd = datetimeLocalFromUnixMillis(rp.EndTimestamp) + } var errs []string if orders, err := h.store.ListOrders("", 25); err == nil { data.Orders = orders @@ -102,6 +114,105 @@ func (h *dashboardHandler) index(w http.ResponseWriter, r *http.Request) { } } +func (h *dashboardHandler) presence(w http.ResponseWriter, r *http.Request) { + if !h.authorized(r) { + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodPost { + http.Redirect(w, r, dashboardURL("/", r), http.StatusSeeOther) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + cfg := RichPresenceConfig{ + Enabled: r.FormValue("enabled") == "on", + Status: strings.TrimSpace(r.FormValue("status")), + ActivityType: strings.TrimSpace(r.FormValue("activity_type")), + Name: strings.TrimSpace(r.FormValue("name")), + State: strings.TrimSpace(r.FormValue("state")), + Details: strings.TrimSpace(r.FormValue("details")), + StartTimestamp: unixMillisFromDatetimeLocal(r.FormValue("start")), + EndTimestamp: unixMillisFromDatetimeLocal(r.FormValue("end")), + LargeImage: strings.TrimSpace(r.FormValue("large_image")), + LargeText: strings.TrimSpace(r.FormValue("large_text")), + SmallImage: strings.TrimSpace(r.FormValue("small_image")), + SmallText: strings.TrimSpace(r.FormValue("small_text")), + InviteChannelID: strings.TrimSpace(r.FormValue("invite_channel_id")), + InvitePostChannelID: strings.TrimSpace(r.FormValue("invite_post_channel_id")), + InviteButtonLabel: strings.TrimSpace(r.FormValue("invite_button_label")), + InvitePostTitle: strings.TrimSpace(r.FormValue("invite_post_title")), + InvitePostText: strings.TrimSpace(r.FormValue("invite_post_text")), + } + old, _ := h.store.GetRichPresenceConfig() + cfg.LastInviteChannelID = old.LastInviteChannelID + cfg.LastInviteMessageID = old.LastInviteMessageID + if cfg.Status == "" { + cfg.Status = "online" + } + if cfg.ActivityType == "" { + cfg.ActivityType = "watching" + } + if cfg.InviteButtonLabel == "" { + cfg.InviteButtonLabel = "Channel beitreten" + } + if cfg.InvitePostTitle == "" { + cfg.InvitePostTitle = "Gemeinsame Session" + } + if err := h.store.SaveRichPresenceConfig(cfg); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if h.bot != nil { + if err := h.bot.SetRichPresence(cfg); err != nil { + http.Error(w, "Gespeichert, aber Presence konnte nicht gesetzt werden: "+err.Error(), http.StatusInternalServerError) + return + } + } + http.Redirect(w, r, dashboardURL("/?presence=saved", r), http.StatusSeeOther) +} + +func (h *dashboardHandler) presenceInvite(w http.ResponseWriter, r *http.Request) { + if !h.authorized(r) { + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodPost { + http.Redirect(w, r, dashboardURL("/", r), http.StatusSeeOther) + return + } + cfg, err := h.store.GetRichPresenceConfig() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if h.bot == nil { + http.Error(w, "Bot-Session nicht verfügbar", http.StatusInternalServerError) + return + } + if _, err := h.bot.SendRichPresenceInvite(cfg); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, dashboardURL("/?presence=invite", r), http.StatusSeeOther) +} + +func dashboardURL(path string, r *http.Request) string { + token := r.URL.Query().Get("token") + if token == "" { + return path + } + sep := "?" + if strings.Contains(path, "?") { + sep = "&" + } + return path + sep + "token=" + token +} + func intFromQuery(r *http.Request, key string, fallback int) int { v := strings.TrimSpace(r.URL.Query().Get(key)) if v == "" { @@ -137,7 +248,7 @@ var dashboardTemplate = template.Must(template.New("dashboard").Funcs(template.F SF Trading Dashboard +:root{--bg:#0f172a;--panel:#111827;--card:#1f2937;--text:#e5e7eb;--muted:#94a3b8;--accent:#38bdf8;--green:#22c55e;--red:#ef4444;--yellow:#f59e0b}*{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:var(--bg);color:var(--text)}header{padding:24px 28px;border-bottom:1px solid #334155;background:#020617;position:sticky;top:0;z-index:1}h1{margin:0;font-size:24px}main{padding:24px;display:grid;gap:20px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px}.card{background:var(--card);border:1px solid #334155;border-radius:16px;padding:16px;box-shadow:0 10px 24px rgba(0,0,0,.22)}.metric{font-size:28px;font-weight:700}.muted{color:var(--muted)}table{width:100%;border-collapse:collapse;font-size:14px}th,td{padding:9px 8px;border-bottom:1px solid #334155;text-align:left;vertical-align:top}th{color:#cbd5e1;background:#111827}.scroll{overflow:auto}.badge{display:inline-block;border-radius:999px;padding:2px 8px;background:#334155;color:#dbeafe;font-size:12px}.err{background:#7f1d1d;border-color:#fecaca}.filters{display:flex;gap:8px;align-items:end;flex-wrap:wrap}.filters input{background:#020617;color:var(--text);border:1px solid #475569;border-radius:10px;padding:8px}.filters button,.btn{background:var(--accent);border:0;border-radius:10px;padding:9px 14px;font-weight:700;color:#00111c;text-decoration:none;cursor:pointer}.formgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:12px}.formgrid input,.formgrid select,.formgrid textarea{width:100%;background:#020617;color:var(--text);border:1px solid #475569;border-radius:10px;padding:8px}.formgrid textarea{min-height:76px}.actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px}.hint{font-size:13px;color:var(--muted);line-height:1.4}a{color:#7dd3fc}

SF Trading Dashboard

Stand: {{.Now.Format "02.01.2006 15:04"}}
@@ -152,6 +263,33 @@ var dashboardTemplate = template.Must(template.New("dashboard").Funcs(template.F Filter betrifft Trading-Auswertung und Abrechnung. +

Rich Presence & Session-Einladung

+

Admin-Steuerung über das Webinterface. Hinweis: Discord erlaubt Bot-Activities offiziell nur eingeschränkt; der zuverlässige Einladungsbutton wird deshalb zusätzlich als Discord-Post mit Link-Button erzeugt.

+
+
+ + + + + + + + + + + + + + + + + +
+
+
+
{{if .Presence.LastInviteMessageID}}Letzter Post: #{{.Presence.LastInviteMessageID}}{{end}}
+
+ {{with .TradingSummary}}
Trading-Einträge
{{.Count}}