Update mit Presence-Einstellungen
All checks were successful
release-tag / release-image (push) Successful in 2m8s

This commit is contained in:
2026-05-31 08:34:07 +02:00
parent f78274ed84
commit 25b42c8335
6 changed files with 411 additions and 4 deletions

View File

@@ -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/<GuildID>/<ChannelID>
```
Der Bot benötigt im Invite-Post-Channel:
```text
View Channel
Send Messages
Embed Links
```

25
db.go
View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}

192
rich_presence.go Normal file
View File

@@ -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")
}

View File

@@ -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
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SF Trading Dashboard</title>
<style>
: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{background:var(--accent);border:0;border-radius:10px;padding:9px 14px;font-weight:700;color:#00111c}a{color:#7dd3fc}</style>
: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}</style>
</head>
<body>
<header><h1>SF Trading Dashboard</h1><div class="muted">Stand: {{.Now.Format "02.01.2006 15:04"}}</div></header>
@@ -152,6 +263,33 @@ var dashboardTemplate = template.Must(template.New("dashboard").Funcs(template.F
<span class="muted">Filter betrifft Trading-Auswertung und Abrechnung.</span>
</form>
</section>
<section class="card"><h2>Rich Presence & Session-Einladung</h2>
<p class="hint">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.</p>
<form method="post" action="/presence{{if .Token}}?token={{.Token}}{{end}}">
<div class="formgrid">
<label><input type="checkbox" name="enabled" {{if .Presence.Enabled}}checked{{end}}> Presence aktiv</label>
<label>Status<br><select name="status"><option value="online" {{if eq .Presence.Status "online"}}selected{{end}}>online</option><option value="idle" {{if eq .Presence.Status "idle"}}selected{{end}}>idle</option><option value="dnd" {{if eq .Presence.Status "dnd"}}selected{{end}}>nicht stören</option><option value="invisible" {{if eq .Presence.Status "invisible"}}selected{{end}}>unsichtbar</option></select></label>
<label>Activity-Typ<br><select name="activity_type"><option value="playing" {{if eq .Presence.ActivityType "playing"}}selected{{end}}>Spielt</option><option value="watching" {{if eq .Presence.ActivityType "watching"}}selected{{end}}>Schaut</option><option value="listening" {{if eq .Presence.ActivityType "listening"}}selected{{end}}>Hört</option><option value="competing" {{if eq .Presence.ActivityType "competing"}}selected{{end}}>Tritt an</option><option value="custom" {{if eq .Presence.ActivityType "custom"}}selected{{end}}>Custom</option></select></label>
<label>Name<br><input name="name" value="{{.Presence.Name}}" placeholder="Star Citizen"></label>
<label>State<br><input name="state" value="{{.Presence.State}}" placeholder="Mining Session"></label>
<label>Details<br><input name="details" value="{{.Presence.Details}}" placeholder="Join im Voice"></label>
<label>Start<br><input type="datetime-local" name="start" value="{{.PresenceStart}}"></label>
<label>Ende<br><input type="datetime-local" name="end" value="{{.PresenceEnd}}"></label>
<label>Large Image Asset-Key<br><input name="large_image" value="{{.Presence.LargeImage}}" placeholder="sc_logo"></label>
<label>Large Image Text<br><input name="large_text" value="{{.Presence.LargeText}}"></label>
<label>Small Image Asset-Key<br><input name="small_image" value="{{.Presence.SmallImage}}"></label>
<label>Small Image Text<br><input name="small_text" value="{{.Presence.SmallText}}"></label>
<label>Discord-Zielchannel-ID<br><input name="invite_channel_id" value="{{.Presence.InviteChannelID}}" placeholder="Voice/Text Channel ID"></label>
<label>Post-Channel-ID<br><input name="invite_post_channel_id" value="{{.Presence.InvitePostChannelID}}" placeholder="Channel für Einladungs-Post"></label>
<label>Button-Label<br><input name="invite_button_label" value="{{.Presence.InviteButtonLabel}}" placeholder="Channel beitreten"></label>
<label>Post-Titel<br><input name="invite_post_title" value="{{.Presence.InvitePostTitle}}"></label>
<label style="grid-column:1/-1">Post-Text<br><textarea name="invite_post_text">{{.Presence.InvitePostText}}</textarea></label>
</div>
<div class="actions"><button class="btn" type="submit">Speichern & Presence setzen</button></div>
</form>
<form method="post" action="/presence/invite{{if .Token}}?token={{.Token}}{{end}}" class="actions"><button class="btn" type="submit">Session-Invite posten</button>{{if .Presence.LastInviteMessageID}}<span class="muted">Letzter Post: #{{.Presence.LastInviteMessageID}}</span>{{end}}</form>
</section>
{{with .TradingSummary}}
<section class="grid">
<div class="card"><div class="muted">Trading-Einträge</div><div class="metric">{{.Count}}</div></div>