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, ) }