Files
sftrading/trading_discord.go
jbergner 4864574c56
release-tag / release-image (push) Successful in 2m9s
Charts-Update
2026-05-31 16:04:59 +02:00

424 lines
17 KiB
Go

package main
import (
"bytes"
"fmt"
"log"
"math"
"sort"
"strings"
"time"
"github.com/bwmarrin/discordgo"
)
func tradingCommands() []*discordgo.ApplicationCommand {
return []*discordgo.ApplicationCommand{
{Name: "trading_add", Description: "Erfasst einen Trading-Run", Options: []*discordgo.ApplicationCommandOption{
{Name: "zweck", Description: "Zweck des Runs", Type: discordgo.ApplicationCommandOptionString, Required: true, Choices: tradingPurposeChoices()},
{Name: "schiff", Description: "Schiff", Type: discordgo.ApplicationCommandOptionString, Required: true, Autocomplete: true},
{Name: "einkaufswert", Description: "Einkaufswert in aUEC", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(0)},
{Name: "verkaufswert", Description: "Verkaufswert in aUEC", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(0)},
{Name: "kosten", Description: "Kosten in aUEC", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(0)},
{Name: "wiederholungen", Description: "Anzahl Wiederholungen", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(1)},
{Name: "scu", Description: "Transportierte SCU je Wiederholung", Type: discordgo.ApplicationCommandOptionNumber, Required: true, MinValue: ptrFloat(0)},
{Name: "distanz", Description: "Distanz je Wiederholung", Type: discordgo.ApplicationCommandOptionNumber, Required: true, MinValue: ptrFloat(0)},
{Name: "scu_gr", Description: "SCU-Größe / Container-Größe", Type: discordgo.ApplicationCommandOptionString, Required: true},
{Name: "ware", Description: "Ware", Type: discordgo.ApplicationCommandOptionString, Required: true, Autocomplete: true},
{Name: "tradingpost_a", Description: "Start / Einkauf", Type: discordgo.ApplicationCommandOptionString, Required: true},
{Name: "tradingpost_b", Description: "Ziel / Verkauf", Type: discordgo.ApplicationCommandOptionString, Required: true},
{Name: "monat", Description: "Monat 1-12, Standard aktueller Monat", Type: discordgo.ApplicationCommandOptionInteger, Required: false, MinValue: ptrFloat(1), MaxValue: float64(12)},
{Name: "jahr", Description: "Jahr, Standard aktuelles Jahr", Type: discordgo.ApplicationCommandOptionInteger, Required: false, MinValue: ptrFloat(2000), MaxValue: float64(2200)},
{Name: "bemerkung", Description: "Optionale Bemerkung", Type: discordgo.ApplicationCommandOptionString, Required: false},
}},
{Name: "trading_liste", Description: "Zeigt Trading-Einträge mit Filter", Options: []*discordgo.ApplicationCommandOption{
{Name: "monat", Description: "Monat 1-12", Type: discordgo.ApplicationCommandOptionInteger, Required: false, MinValue: ptrFloat(1), MaxValue: float64(12)},
{Name: "jahr", Description: "Jahr", Type: discordgo.ApplicationCommandOptionInteger, Required: false, MinValue: ptrFloat(2000), MaxValue: float64(2200)},
{Name: "nutzer", Description: "Discord-Nutzer", Type: discordgo.ApplicationCommandOptionUser, Required: false},
{Name: "limit", Description: "Maximal 50", Type: discordgo.ApplicationCommandOptionInteger, Required: false, MinValue: ptrFloat(1), MaxValue: float64(50)},
}},
{Name: "trading_delete", Description: "Admin: löscht einen Trading-Eintrag", Options: []*discordgo.ApplicationCommandOption{
{Name: "id", Description: "Trading-ID", Type: discordgo.ApplicationCommandOptionInteger, Required: true},
}},
{Name: "trading_report", Description: "Admin: postet die Monatsauswertung", Options: []*discordgo.ApplicationCommandOption{
{Name: "monat", Description: "Monat 1-12", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(1), MaxValue: float64(12)},
{Name: "jahr", Description: "Jahr", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(2000), MaxValue: float64(2200)},
}},
{Name: "trading_abrechnung", Description: "Admin: sendet Mitgliedern ihre monatliche Trading-Abrechnung per DM", Options: []*discordgo.ApplicationCommandOption{
{Name: "monat", Description: "Monat 1-12", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(1), MaxValue: float64(12)},
{Name: "jahr", Description: "Jahr", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(2000), MaxValue: float64(2200)},
}},
}
}
func tradingPurposeChoices() []*discordgo.ApplicationCommandOptionChoice {
return []*discordgo.ApplicationCommandOptionChoice{
{Name: "Trade", Value: TradingPurposeTrade},
{Name: "Hauling", Value: TradingPurposeHauling},
{Name: "Event", Value: TradingPurposeEvent},
{Name: "Intern", Value: TradingPurposeIntern},
}
}
func (b *Bot) cmdTradingAdd(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !b.canUseTrading(i) {
b.replyEphemeral(s, i, "Du hast keine Berechtigung, Trading-Runs einzutragen.")
return
}
now := time.Now()
month := int(optIntDefault(i, "monat", int64(now.Month())))
year := int(optIntDefault(i, "jahr", int64(now.Year())))
purpose := optString(i, "zweck")
ship := strings.TrimSpace(optString(i, "schiff"))
commodity := strings.TrimSpace(optString(i, "ware"))
if !validTradingPurpose(purpose) {
b.replyEphemeral(s, i, "Ungültiger Zweck.")
return
}
if ship == "" || commodity == "" {
b.replyEphemeral(s, i, "Schiff und Ware sind Pflichtfelder.")
return
}
if len(b.cfg.TradingShips) > 0 && !containsFold(b.cfg.TradingShips, ship) {
b.replyEphemeral(s, i, "Dieses Schiff ist nicht in TRADING_SHIPS konfiguriert.")
return
}
if len(b.cfg.TradingCommodities) > 0 && !containsFold(b.cfg.TradingCommodities, commodity) {
b.replyEphemeral(s, i, "Diese Ware ist nicht in TRADING_COMMODITIES konfiguriert.")
return
}
run := &TradingRun{
UserID: userID(i),
UserName: userString(i),
Purpose: purpose,
Ship: ship,
BuyValue: optInt(i, "einkaufswert"),
SellValue: optInt(i, "verkaufswert"),
Costs: optInt(i, "kosten"),
Repetitions: optInt(i, "wiederholungen"),
SCU: optFloat(i, "scu"),
Distance: optFloat(i, "distanz"),
SCUGrade: strings.TrimSpace(optString(i, "scu_gr")),
Commodity: commodity,
PostA: strings.TrimSpace(optString(i, "tradingpost_a")),
PostB: strings.TrimSpace(optString(i, "tradingpost_b")),
Note: strings.TrimSpace(optString(i, "bemerkung")),
Month: month,
Year: year,
OrgPercent: b.cfg.TradingOrgSharePercent[purpose],
}
id, err := b.store.CreateTradingRun(run)
if err != nil {
b.replyEphemeral(s, i, "Trading-Run konnte nicht gespeichert werden: "+err.Error())
return
}
run.ID = id
b.audit(fmt.Sprintf("Trading-Run #%d eingetragen durch %s: %s, Gewinn %s aUEC, Orga-Anteil %s aUEC", id, userString(i), run.Commodity, formatInt(tradingProfit(*run)), formatInt(tradingOrgShare(*run))))
b.replyEphemeral(s, i, fmt.Sprintf("Trading-Run #%d gespeichert. Gewinn: %s aUEC, Orga-Anteil: %s aUEC.", id, formatInt(tradingProfit(*run)), formatInt(tradingOrgShare(*run))))
}
func (b *Bot) cmdTradingList(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !b.canUseTrading(i) {
b.replyEphemeral(s, i, "Du hast keine Berechtigung, Trading-Runs anzuzeigen.")
return
}
filter := TradingListFilter{
Month: int(optInt(i, "monat")),
Year: int(optInt(i, "jahr")),
Limit: int(optIntDefault(i, "limit", 20)),
}
if u := optUser(i, s, "nutzer"); u != nil {
filter.UserID = u.ID
}
if filter.UserID == "" && !b.isTeamOrAdmin(i) {
filter.UserID = userID(i)
}
runs, err := b.store.ListTradingRuns(filter)
if err != nil {
b.replyEphemeral(s, i, err.Error())
return
}
if len(runs) == 0 {
b.replyEphemeral(s, i, "Keine Trading-Einträge gefunden.")
return
}
b.replyEphemeral(s, i, "Trading-Einträge\n"+tradingTable(runs))
}
func (b *Bot) cmdTradingDelete(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !b.isAdmin(i) {
b.replyEphemeral(s, i, "Nur Admins dürfen Trading-Einträge löschen.")
return
}
id := optInt(i, "id")
run, err := b.store.DeleteTradingRun(id, userID(i))
if err != nil {
b.replyEphemeral(s, i, "Eintrag konnte nicht gelöscht werden: "+err.Error())
return
}
b.audit(fmt.Sprintf("Trading-Run #%d gelöscht durch %s. Ursprünglicher Nutzer: %s", id, userString(i), run.UserName))
b.replyEphemeral(s, i, fmt.Sprintf("Trading-Run #%d wurde gelöscht.", id))
}
func (b *Bot) cmdTradingReport(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !b.isAdmin(i) {
b.replyEphemeral(s, i, "Nur Admins dürfen Trading-Reports posten.")
return
}
month := int(optInt(i, "monat"))
year := int(optInt(i, "jahr"))
summary, err := b.store.SummarizeTradingRuns(month, year)
if err != nil {
b.replyEphemeral(s, i, "Report konnte nicht erstellt werden: "+err.Error())
return
}
if summary.Count == 0 {
b.replyEphemeral(s, i, "Für diesen Monat gibt es keine Trading-Einträge.")
return
}
prevMonth, prevYear := previousMonth(month, year)
prevSummary, err := b.store.SummarizeTradingRuns(prevMonth, prevYear)
if err != nil {
prevSummary = &TradingSummary{Month: prevMonth, Year: prevYear}
}
channelID := b.cfg.TradingReportChannelID
if channelID == "" {
channelID = b.cfg.InternalOrderChannelID
}
msg := &discordgo.MessageSend{Embeds: []*discordgo.MessageEmbed{b.tradingReportEmbed(summary, prevSummary)}}
if comparisonPNG, purposePNG, chartErr := createTradingCharts(summary, prevSummary); chartErr == nil {
msg.Files = []*discordgo.File{
{Name: "trading_compare.png", Reader: bytes.NewReader(comparisonPNG), ContentType: "image/png"},
{Name: "trading_purpose.png", Reader: bytes.NewReader(purposePNG), ContentType: "image/png"},
}
msg.Embeds = append(msg.Embeds,
&discordgo.MessageEmbed{Title: "Vormonatsvergleich", Description: fmt.Sprintf("Vergleich %02d/%d zu %02d/%d", month, year, prevMonth, prevYear), Color: 0x3498db, Image: &discordgo.MessageEmbedImage{URL: "attachment://trading_compare.png"}},
&discordgo.MessageEmbed{Title: "Verteilung nach Zweck", Description: fmt.Sprintf("Monat %02d/%d", month, year), Color: 0xf1c40f, Image: &discordgo.MessageEmbedImage{URL: "attachment://trading_purpose.png"}},
)
} else {
log.Println("trading charts failed:", chartErr)
}
_, err = s.ChannelMessageSendComplex(channelID, msg)
if err != nil {
b.replyEphemeral(s, i, "Report konnte nicht gepostet werden: "+err.Error())
return
}
b.audit(fmt.Sprintf("Trading-Report %02d/%d gepostet durch %s", month, year, userString(i)))
b.replyEphemeral(s, i, fmt.Sprintf("Trading-Report %02d/%d wurde gepostet. Vergleich zu %02d/%d:\n%s", month, year, prevMonth, prevYear, comparisonSummaryText(summary, prevSummary)))
}
func (b *Bot) tradingReportEmbed(sum, prev *TradingSummary) *discordgo.MessageEmbed {
fields := []*discordgo.MessageEmbedField{
{Name: "Einträge", Value: fmt.Sprintf("%d", sum.Count), Inline: true},
{Name: "Gewinn gesamt", Value: formatAUEC(sum.GrossProfit), Inline: true},
{Name: "Orga-Anteil", Value: formatAUEC(sum.OrgShare), Inline: true},
{Name: "Transportierte SCU", Value: formatAmount(sum.TotalSCU), Inline: true},
{Name: "Kosten gesamt", Value: formatAUEC(sum.TotalCosts), Inline: true},
{Name: "Distanz gesamt", Value: formatAmount(sum.TotalDistance), Inline: true},
{Name: "Vergleich zum Vormonat", Value: comparisonSummaryText(sum, prev), Inline: false},
{Name: "Top 3 Trader", Value: topTraderText(sum.TopTraders), Inline: false},
{Name: "Nach Zweck", Value: purposeSummaryText(sum.PurposeSummary), Inline: false},
}
return &discordgo.MessageEmbed{Title: fmt.Sprintf("Trading-Auswertung %02d/%d", sum.Month, sum.Year), Description: "Monatliche Zusammenfassung der erfassten Trading-Runs inklusive Vergleich zum Vormonat.", Color: 0x2ecc71, Fields: fields, Timestamp: time.Now().UTC().Format(time.RFC3339)}
}
func topTraderText(items []TradingTraderSummary) string {
if len(items) == 0 {
return "Keine Daten"
}
var lines []string
for idx, t := range items {
name := t.UserName
if t.UserID != "" {
name = fmt.Sprintf("<@%s>", t.UserID)
}
lines = append(lines, fmt.Sprintf("%d. %s — Gewinn %s, Orga %s, SCU %s, Runs %d", idx+1, name, formatAUEC(t.Profit), formatAUEC(t.OrgShare), formatAmount(t.SCU), t.Runs))
}
return strings.Join(lines, "\n")
}
func purposeSummaryText(items []TradingPurposeSummary) string {
if len(items) == 0 {
return "Keine Daten"
}
var lines []string
for _, p := range items {
lines = append(lines, fmt.Sprintf("%s — Gewinn %s, Orga %s, SCU %s, Runs %d", tradingPurposeLabel(p.Purpose), formatAUEC(p.Profit), formatAUEC(p.OrgShare), formatAmount(p.SCU), p.Runs))
}
return strings.Join(lines, "\n")
}
func tradingTable(runs []TradingRun) string {
var b strings.Builder
b.WriteString("```text\n")
b.WriteString(fmt.Sprintf("%-4s %-7s %-15s %-9s %-14s %-12s %-12s %-8s\n", "ID", "Monat", "Nutzer", "Zweck", "Ware", "Gewinn", "Orga", "SCU"))
b.WriteString(strings.Repeat("-", 90) + "\n")
for _, r := range runs {
line := fmt.Sprintf("%-4d %02d/%-4d %-15s %-9s %-14s %-12s %-12s %-8s\n", r.ID, r.Month, r.Year, truncateText(r.UserName, 15), tradingPurposeLabel(r.Purpose), truncateText(r.Commodity, 14), formatInt(tradingProfit(r)), formatInt(tradingOrgShare(r)), formatAmount(r.SCU*float64(r.Repetitions)))
if b.Len()+len(line)+4 > 1900 {
b.WriteString("… Ausgabe gekürzt\n")
break
}
b.WriteString(line)
}
b.WriteString("```")
return b.String()
}
func (b *Bot) canUseTrading(i *discordgo.InteractionCreate) bool {
return b.isTeamOrAdmin(i) || hasAnyRole(i, b.cfg.TradingRoleIDs)
}
func validTradingPurpose(v string) bool {
switch v {
case TradingPurposeTrade, TradingPurposeHauling, TradingPurposeEvent, TradingPurposeIntern:
return true
default:
return false
}
}
func tradingPurposeLabel(v string) string {
switch v {
case TradingPurposeTrade:
return "Trade"
case TradingPurposeHauling:
return "Hauling"
case TradingPurposeEvent:
return "Event"
case TradingPurposeIntern:
return "Intern"
default:
return v
}
}
func formatAUEC(v int64) string { return formatInt(v) + " aUEC" }
func formatInt(v int64) string {
neg := v < 0
if neg {
v = -v
}
s := fmt.Sprintf("%d", v)
for i := len(s) - 3; i > 0; i -= 3 {
s = s[:i] + "." + s[i:]
}
if neg {
return "-" + s
}
return s
}
func containsFold(values []string, needle string) bool {
needle = strings.TrimSpace(needle)
for _, v := range values {
if strings.EqualFold(strings.TrimSpace(v), needle) {
return true
}
}
return false
}
func autocompleteFromList(values []string, query string, limit int) []*discordgo.ApplicationCommandOptionChoice {
query = strings.ToLower(strings.TrimSpace(query))
var filtered []string
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" {
continue
}
if query == "" || strings.Contains(strings.ToLower(v), query) {
filtered = append(filtered, v)
}
}
sort.Slice(filtered, func(i, j int) bool { return strings.ToLower(filtered[i]) < strings.ToLower(filtered[j]) })
if limit <= 0 || limit > 25 {
limit = 25
}
if len(filtered) > limit {
filtered = filtered[:limit]
}
choices := make([]*discordgo.ApplicationCommandOptionChoice, 0, len(filtered))
for _, v := range filtered {
choices = append(choices, &discordgo.ApplicationCommandOptionChoice{Name: v, Value: v})
}
return choices
}
func optUser(i *discordgo.InteractionCreate, s *discordgo.Session, name string) *discordgo.User {
for _, o := range i.ApplicationCommandData().Options {
if o.Name == name {
return o.UserValue(s)
}
}
return nil
}
func roundMoney(v float64) int64 { return int64(math.Round(v)) }
func (b *Bot) cmdTradingSettlement(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !b.isAdmin(i) {
b.replyEphemeral(s, i, "Nur Admins dürfen Trading-Abrechnungen versenden.")
return
}
month := int(optInt(i, "monat"))
year := int(optInt(i, "jahr"))
users, err := b.store.SummarizeTradingByUser(month, year)
if err != nil {
b.replyEphemeral(s, i, "Abrechnung konnte nicht erstellt werden: "+err.Error())
return
}
if len(users) == 0 {
b.replyEphemeral(s, i, "Für diesen Monat gibt es keine Trading-Einträge.")
return
}
sent, skipped, failed := 0, 0, 0
var failedNames []string
for _, u := range users {
if u.OrgShare <= 0 {
skipped++
continue
}
if strings.TrimSpace(u.UserID) == "" {
failed++
failedNames = append(failedNames, u.UserName)
continue
}
body := b.tradingSettlementText(u, month, year)
if err := b.dmErr(u.UserID, body); err != nil {
failed++
failedNames = append(failedNames, u.UserName)
log.Println("trading settlement dm failed:", u.UserName, err)
continue
}
sent++
}
msg := fmt.Sprintf("Abrechnung %02d/%d versendet. Erfolgreich: %d, übersprungen ohne Orga-Anteil: %d, fehlgeschlagen: %d.", month, year, sent, skipped, failed)
if len(failedNames) > 0 {
msg += "\nFehlgeschlagen: " + strings.Join(failedNames, ", ")
}
b.audit(fmt.Sprintf("Trading-Abrechnung %02d/%d durch %s versendet: %d erfolgreich, %d ohne Anteil, %d fehlgeschlagen", month, year, userString(i), sent, skipped, failed))
b.replyEphemeral(s, i, msg)
}
func (b *Bot) tradingSettlementText(u TradingTraderSummary, month, year int) string {
return fmt.Sprintf(
"Abrechnung Trading %02d/%d\n\nHallo %s,\n\nfür deine erfassten Trading-Runs ergibt sich folgende Abrechnung:\n\nRuns: %d\nGewinn: %s\nTransportierte SCU: %s\nDein Orga-Anteil: %s\n\nBitte überweise den Orga-Anteil an: %s\n\nDanke dir!",
month,
year,
u.UserName,
u.Runs,
formatAUEC(u.Profit),
formatAmount(u.SCU),
formatAUEC(u.OrgShare),
b.cfg.TradingPayoutRecipient,
)
}