Files
sftrading/discord.go
jbergner f78274ed84
All checks were successful
release-tag / release-image (push) Successful in 2m18s
Dasdhboard und Abrechnung
2026-05-30 15:33:35 +02:00

963 lines
38 KiB
Go

package main
import (
"database/sql"
"errors"
"fmt"
"log"
"strconv"
"strings"
"time"
"github.com/bwmarrin/discordgo"
)
type Bot struct {
cfg Config
store *Store
dg *discordgo.Session
cleanupStop chan struct{}
}
func NewBot(cfg Config, store *Store) (*Bot, error) {
dg, err := discordgo.New("Bot " + cfg.Token)
if err != nil {
return nil, err
}
dg.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsDirectMessages
b := &Bot{cfg: cfg, store: store, dg: dg, cleanupStop: make(chan struct{})}
dg.AddHandler(b.onReady)
dg.AddHandler(b.onInteraction)
return b, nil
}
func (b *Bot) Start() error {
if err := b.dg.Open(); err != nil {
return err
}
if err := b.registerCommands(); err != nil {
return err
}
b.startThreadCleanup()
return nil
}
func (b *Bot) Stop() {
close(b.cleanupStop)
_ = b.dg.Close()
}
func (b *Bot) onReady(s *discordgo.Session, r *discordgo.Ready) {
log.Println("Bot ist online als", r.User.String())
}
func (b *Bot) registerCommands() error {
commands := []*discordgo.ApplicationCommand{
{
Name: "auftrag", Description: "Erstellt einen Lieferauftrag",
Options: []*discordgo.ApplicationCommandOption{
{Name: "ware", Description: "Commodity oder Item", Type: discordgo.ApplicationCommandOptionString, Required: true},
{Name: "qualitaet", Description: "Qualität 0-1000", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(0), MaxValue: float64(1000)},
{Name: "menge", Description: "Menge als Zahl", Type: discordgo.ApplicationCommandOptionNumber, Required: true, MinValue: ptrFloat(0.0001)},
{Name: "einheit", Description: "SCU/cSCU für Commodities, Stück für Items", Type: discordgo.ApplicationCommandOptionString, Required: true, Choices: unitChoices()},
{Name: "frist", Description: "Datum im Format YYYY-MM-DD", Type: discordgo.ApplicationCommandOptionString, Required: true},
{Name: "lieferort", Description: "Liefer-Ort als Freitext", Type: discordgo.ApplicationCommandOptionString, Required: true},
{Name: "budget", Description: "Maximales Budget in aUEC", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(0)},
},
},
{Name: "auftrag_status", Description: "Zeigt einen Auftrag", Options: []*discordgo.ApplicationCommandOption{{Name: "id", Description: "Auftrags-ID", Type: discordgo.ApplicationCommandOptionInteger, Required: true}}},
{Name: "auftrag_liste", Description: "Listet aktive Aufträge", Options: []*discordgo.ApplicationCommandOption{{Name: "status", Description: "Optionaler aktiver Status", Type: discordgo.ApplicationCommandOptionString, Required: false, Choices: activeStatusChoices()}, {Name: "limit", Description: "Maximal 50", Type: discordgo.ApplicationCommandOptionInteger, Required: false, MinValue: ptrFloat(1), MaxValue: float64(50)}}},
{Name: "auftrag_archiv", Description: "Listet abgeschlossene, abgelehnte und abgebrochene Aufträge", Options: []*discordgo.ApplicationCommandOption{{Name: "status", Description: "Optionaler Archiv-Status", Type: discordgo.ApplicationCommandOptionString, Required: false, Choices: archivedStatusChoices()}, {Name: "limit", Description: "Maximal 50", Type: discordgo.ApplicationCommandOptionInteger, Required: false, MinValue: ptrFloat(1), MaxValue: float64(50)}}},
{Name: "auftrag_abschliessen", Description: "Markiert einen Auftrag als abgeschlossen", Options: []*discordgo.ApplicationCommandOption{{Name: "id", Description: "Auftrags-ID", Type: discordgo.ApplicationCommandOptionInteger, Required: true}}},
{Name: "auftrag_status_setzen", Description: "Admin/Team: setzt den Status eines Auftrags", Options: []*discordgo.ApplicationCommandOption{{Name: "id", Description: "Auftrags-ID", Type: discordgo.ApplicationCommandOptionInteger, Required: true}, {Name: "status", Description: "Neuer Status", Type: discordgo.ApplicationCommandOptionString, Required: true, Choices: statusChoices()}, {Name: "notiz", Description: "Interne Notiz", Type: discordgo.ApplicationCommandOptionString, Required: false}}},
{Name: "auftrag_abbrechen", Description: "Bricht einen Auftrag ab", Options: []*discordgo.ApplicationCommandOption{{Name: "id", Description: "Auftrags-ID", Type: discordgo.ApplicationCommandOptionInteger, Required: true}, {Name: "grund", Description: "Optionaler Grund", Type: discordgo.ApplicationCommandOptionString, Required: false}}},
{Name: "auftrag_nachricht", Description: "Sendet eine Nachricht an Einreicher und Annehmer", Options: []*discordgo.ApplicationCommandOption{{Name: "id", Description: "Auftrags-ID", Type: discordgo.ApplicationCommandOptionInteger, Required: true}, {Name: "text", Description: "Nachricht", Type: discordgo.ApplicationCommandOptionString, Required: true}}},
{Name: "lager_add", Description: "Admin: Lagerbestand hinzufügen oder Bestand setzen", Options: []*discordgo.ApplicationCommandOption{{Name: "ware", Description: "Commodity oder Item", Type: discordgo.ApplicationCommandOptionString, Required: true}, {Name: "qualitaet", Description: "Qualität 0-1000", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(0), MaxValue: float64(1000)}, {Name: "menge", Description: "Menge als Zahl", Type: discordgo.ApplicationCommandOptionNumber, Required: true}, {Name: "einheit", Description: "SCU, cSCU oder Stück", Type: discordgo.ApplicationCommandOptionString, Required: true, Choices: unitChoices()}, {Name: "ort", Description: "Lagerort", Type: discordgo.ApplicationCommandOptionString, Required: true, Autocomplete: true}, {Name: "notiz", Description: "Interne Notiz", Type: discordgo.ApplicationCommandOptionString, Required: false}, {Name: "modus", Description: "addiert oder setzt Bestand", Type: discordgo.ApplicationCommandOptionString, Required: false, Choices: []*discordgo.ApplicationCommandOptionChoice{{Name: "addieren", Value: "add"}, {Name: "setzen", Value: "set"}}}}},
{Name: "lager_remove", Description: "Admin: Lagerbestand reduzieren", Options: []*discordgo.ApplicationCommandOption{{Name: "ware", Description: "Commodity oder Item", Type: discordgo.ApplicationCommandOptionString, Required: true}, {Name: "qualitaet", Description: "Qualität 0-1000", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(0), MaxValue: float64(1000)}, {Name: "menge", Description: "Menge als Zahl", Type: discordgo.ApplicationCommandOptionNumber, Required: true, MinValue: ptrFloat(0.0001)}, {Name: "einheit", Description: "SCU, cSCU oder Stück", Type: discordgo.ApplicationCommandOptionString, Required: true, Choices: unitChoices()}, {Name: "ort", Description: "Lagerort", Type: discordgo.ApplicationCommandOptionString, Required: true, Autocomplete: true}, {Name: "notiz", Description: "Interne Notiz", Type: discordgo.ApplicationCommandOptionString, Required: false}}},
{Name: "lager_liste", Description: "Admin/Team: zeigt internes Lager", Options: []*discordgo.ApplicationCommandOption{{Name: "suche", Description: "Ware oder Ort", Type: discordgo.ApplicationCommandOptionString, Required: false}, {Name: "limit", Description: "Maximal 50", Type: discordgo.ApplicationCommandOptionInteger, Required: false, MinValue: ptrFloat(1), MaxValue: float64(50)}}},
{Name: "lager_check", Description: "Admin/Team: prüft Lagerbestand für Ware", Options: []*discordgo.ApplicationCommandOption{{Name: "ware", Description: "Commodity oder Item", Type: discordgo.ApplicationCommandOptionString, Required: true}, {Name: "qualitaet", Description: "Mindestqualität 0-1000", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(0), MaxValue: float64(1000)}, {Name: "menge", Description: "Benötigte Menge", Type: discordgo.ApplicationCommandOptionNumber, Required: true, MinValue: ptrFloat(0.0001)}, {Name: "einheit", Description: "SCU, cSCU oder Stück", Type: discordgo.ApplicationCommandOptionString, Required: true, Choices: unitChoices()}}},
{Name: "lagerort_add", Description: "Admin/Team: legt einen Lagerort an", Options: []*discordgo.ApplicationCommandOption{{Name: "name", Description: "Name des Lagerorts", Type: discordgo.ApplicationCommandOptionString, Required: true}}},
{Name: "lagerort_remove", Description: "Admin/Team: löscht einen leeren Lagerort", Options: []*discordgo.ApplicationCommandOption{{Name: "ort", Description: "Lagerort", Type: discordgo.ApplicationCommandOptionString, Required: true, Autocomplete: true}}},
}
commands = append(commands, tradingCommands()...)
for _, cmd := range commands {
if _, err := b.dg.ApplicationCommandCreate(b.cfg.AppID, b.cfg.GuildID, cmd); err != nil {
return fmt.Errorf("command %s: %w", cmd.Name, err)
}
}
return nil
}
func ptrFloat(v float64) *float64 { return &v }
func unitChoices() []*discordgo.ApplicationCommandOptionChoice {
return []*discordgo.ApplicationCommandOptionChoice{
{Name: "SCU", Value: "SCU"},
{Name: "cSCU", Value: "cSCU"},
{Name: "Stück", Value: "Stück"},
}
}
func statusChoices() []*discordgo.ApplicationCommandOptionChoice {
return []*discordgo.ApplicationCommandOptionChoice{
{Name: statusLabel(StatusOpen), Value: StatusOpen},
{Name: statusLabel(StatusAccepted), Value: StatusAccepted},
{Name: statusLabel(StatusInDelivery), Value: StatusInDelivery},
{Name: statusLabel(StatusDelivered), Value: StatusDelivered},
{Name: statusLabel(StatusCompleted), Value: StatusCompleted},
{Name: statusLabel(StatusDeclined), Value: StatusDeclined},
{Name: statusLabel(StatusCancelled), Value: StatusCancelled},
}
}
func activeStatusChoices() []*discordgo.ApplicationCommandOptionChoice {
return []*discordgo.ApplicationCommandOptionChoice{
{Name: statusLabel(StatusOpen), Value: StatusOpen},
{Name: statusLabel(StatusAccepted), Value: StatusAccepted},
{Name: statusLabel(StatusInDelivery), Value: StatusInDelivery},
{Name: statusLabel(StatusDelivered), Value: StatusDelivered},
}
}
func archivedStatusChoices() []*discordgo.ApplicationCommandOptionChoice {
return []*discordgo.ApplicationCommandOptionChoice{
{Name: statusLabel(StatusCompleted), Value: StatusCompleted},
{Name: statusLabel(StatusDeclined), Value: StatusDeclined},
{Name: statusLabel(StatusCancelled), Value: StatusCancelled},
}
}
func isArchivedStatus(status string) bool {
return status == StatusCompleted || status == StatusDeclined || status == StatusCancelled
}
func (b *Bot) onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
switch i.Type {
case discordgo.InteractionApplicationCommand:
b.handleCommand(s, i)
case discordgo.InteractionApplicationCommandAutocomplete:
b.handleAutocomplete(s, i)
case discordgo.InteractionMessageComponent:
b.handleButton(s, i)
}
}
func (b *Bot) handleCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
defer func() {
if r := recover(); r != nil {
b.replyEphemeral(s, i, fmt.Sprintf("Fehler: %v", r))
}
}()
switch i.ApplicationCommandData().Name {
case "auftrag":
b.cmdCreateOrder(s, i)
case "auftrag_status":
b.cmdStatus(s, i, optInt(i, "id"))
case "auftrag_liste":
b.cmdList(s, i)
case "auftrag_archiv":
b.cmdArchive(s, i)
case "auftrag_abschliessen":
b.cmdFinish(s, i, optInt(i, "id"))
case "auftrag_status_setzen":
b.cmdSetStatus(s, i, optInt(i, "id"), optString(i, "status"), optString(i, "notiz"))
case "auftrag_abbrechen":
b.cmdCancel(s, i, optInt(i, "id"), optString(i, "grund"))
case "auftrag_nachricht":
b.cmdMessage(s, i, optInt(i, "id"), optString(i, "text"))
case "lager_add":
b.cmdInventoryAdd(s, i, false)
case "lager_remove":
b.cmdInventoryAdd(s, i, true)
case "lager_liste":
b.cmdInventoryList(s, i)
case "lager_check":
b.cmdInventoryCheck(s, i)
case "lagerort_add":
b.cmdStorageLocationAdd(s, i)
case "lagerort_remove":
b.cmdStorageLocationRemove(s, i)
case "trading_add":
b.cmdTradingAdd(s, i)
case "trading_liste":
b.cmdTradingList(s, i)
case "trading_delete":
b.cmdTradingDelete(s, i)
case "trading_report":
b.cmdTradingReport(s, i)
case "trading_abrechnung":
b.cmdTradingSettlement(s, i)
}
}
func (b *Bot) cmdCreateOrder(s *discordgo.Session, i *discordgo.InteractionCreate) {
deadline := optString(i, "frist")
if _, err := time.Parse("2006-01-02", deadline); err != nil {
b.replyEphemeral(s, i, "Bitte Frist im Format YYYY-MM-DD angeben.")
return
}
o := &Order{SubmitterID: userID(i), SubmitterName: userString(i), Commodity: strings.TrimSpace(optString(i, "ware")), Quality: int(optInt(i, "qualitaet")), QuantityAmount: optFloat(i, "menge"), QuantityUnit: optString(i, "einheit"), Deadline: deadline, DeliveryPlace: strings.TrimSpace(optString(i, "lieferort")), MaxBudgetAUEC: optInt(i, "budget")}
if o.Commodity == "" || o.DeliveryPlace == "" {
b.replyEphemeral(s, i, "Ware und Liefer-Ort dürfen nicht leer sein.")
return
}
id, err := b.store.CreateOrder(o, userID(i), userString(i))
if err != nil {
b.replyEphemeral(s, i, "Konnte Auftrag nicht speichern: "+err.Error())
return
}
o, err = b.store.GetOrder(id)
if err != nil {
b.replyEphemeral(s, i, "Auftrag wurde gespeichert, konnte aber nicht erneut gelesen werden: "+err.Error())
return
}
var publicMsgID, publicChannelID string
if b.cfg.PublicOrderChannelID != "" {
msg, err := s.ChannelMessageSendComplex(b.cfg.PublicOrderChannelID, &discordgo.MessageSend{Embeds: []*discordgo.MessageEmbed{publicOrderEmbed(o)}})
if err != nil {
log.Println("public order post failed:", err)
} else {
publicMsgID, publicChannelID = msg.ID, b.cfg.PublicOrderChannelID
}
}
internalMsg, err := s.ChannelMessageSendComplex(b.cfg.InternalOrderChannelID, &discordgo.MessageSend{Embeds: []*discordgo.MessageEmbed{b.internalOrderEmbed(o)}, Components: orderButtons(o.ID, true)})
if err != nil {
b.replyEphemeral(s, i, "Auftrag gespeichert, aber interner Channel-Post fehlgeschlagen: "+err.Error())
return
}
threadID := b.tryCreateThread(internalMsg.ChannelID, internalMsg.ID, fmt.Sprintf("Auftrag-%d-%s", id, safeThreadName(o.Commodity)))
_ = b.store.SetMessages(id, publicChannelID, publicMsgID, internalMsg.ChannelID, internalMsg.ID, threadID)
if threadID != "" {
_, _ = s.ChannelMessageSend(threadID, fmt.Sprintf("Interner Thread zu Auftrag #%d. Einreicher: <@%s>. Lagerdaten bleiben intern.", id, o.SubmitterID))
}
b.audit(fmt.Sprintf("Auftrag #%d erstellt von %s: %s %s", id, userString(i), quantityString(o.QuantityAmount, o.QuantityUnit), o.Commodity))
b.replyEphemeral(s, i, fmt.Sprintf("Auftrag #%d wurde erstellt. Das Team wurde informiert.", id))
}
func (b *Bot) cmdStatus(s *discordgo.Session, i *discordgo.InteractionCreate, id int64) {
o, err := b.store.GetOrder(id)
if err != nil {
b.replyEphemeral(s, i, "Auftrag nicht gefunden.")
return
}
internal := b.isTeamOrAdmin(i)
if !internal && userID(i) != o.SubmitterID && userID(i) != o.AcceptorID {
b.replyEphemeral(s, i, "Du kannst nur eigene Aufträge anzeigen.")
return
}
if internal {
b.respondEmbed(s, i, b.internalOrderEmbed(o), true)
} else {
b.respondEmbed(s, i, publicOrderEmbed(o), true)
}
}
func (b *Bot) cmdList(s *discordgo.Session, i *discordgo.InteractionCreate) {
b.cmdListOrders(s, i, false)
}
func (b *Bot) cmdArchive(s *discordgo.Session, i *discordgo.InteractionCreate) {
b.cmdListOrders(s, i, true)
}
func (b *Bot) cmdListOrders(s *discordgo.Session, i *discordgo.InteractionCreate, archived bool) {
status := optString(i, "status")
limit := int(optIntDefault(i, "limit", 10))
if status != "" && isArchivedStatus(status) != archived {
if archived {
b.replyEphemeral(s, i, "`/auftrag_archiv` zeigt nur abgeschlossene, abgelehnte und abgebrochene Aufträge. Aktive Aufträge findest du mit `/auftrag_liste`.")
} else {
b.replyEphemeral(s, i, "`/auftrag_liste` zeigt nur aktive Aufträge. Abgeschlossene/abgelehnte/abgebrochene Aufträge findest du mit `/auftrag_archiv`.")
}
return
}
var (
orders []Order
err error
)
if archived {
orders, err = b.store.ListArchivedOrders(status, limit)
} else {
orders, err = b.store.ListOrders(status, limit)
}
if err != nil {
b.replyEphemeral(s, i, err.Error())
return
}
if len(orders) == 0 {
if archived {
b.replyEphemeral(s, i, "Keine archivierten Aufträge gefunden.")
} else {
b.replyEphemeral(s, i, "Keine aktiven Aufträge gefunden.")
}
return
}
var lines []string
for _, o := range orders {
line := fmt.Sprintf("#%d — %s, %s, Budget %d aUEC, Frist %s, Status %s", o.ID, o.Commodity, quantityString(o.QuantityAmount, o.QuantityUnit), o.MaxBudgetAUEC, o.Deadline, statusLabel(o.Status))
if b.isTeamOrAdmin(i) {
line += fmt.Sprintf(", Einreicher <@%s>", o.SubmitterID)
}
lines = append(lines, line)
}
b.replyEphemeral(s, i, strings.Join(lines, "\n"))
}
func (b *Bot) cmdFinish(s *discordgo.Session, i *discordgo.InteractionCreate, id int64) {
o, err := b.store.GetOrder(id)
if err != nil {
b.replyEphemeral(s, i, "Auftrag nicht gefunden.")
return
}
uid := userID(i)
if uid != o.SubmitterID && uid != o.AcceptorID && !b.isTeamOrAdmin(i) {
b.replyEphemeral(s, i, "Nur Einreicher, Annehmer oder Team/Admin können abschließen.")
return
}
o, err = b.store.SetStatus(id, StatusCompleted, "Abgeschlossen", uid, userString(i))
if err != nil {
b.replyEphemeral(s, i, err.Error())
return
}
b.updateOrderMessages(o, false)
b.notifyParticipants(o, fmt.Sprintf("Auftrag #%d wurde abgeschlossen.", id))
b.audit(fmt.Sprintf("Auftrag #%d abgeschlossen durch %s", id, userString(i)))
b.replyEphemeral(s, i, fmt.Sprintf("Auftrag #%d wurde abgeschlossen.", id))
}
func (b *Bot) cmdSetStatus(s *discordgo.Session, i *discordgo.InteractionCreate, id int64, status, note string) {
if !b.isTeamOrAdmin(i) {
b.replyEphemeral(s, i, "Nur Team/Admin kann den Status setzen.")
return
}
if note == "" {
note = "Status manuell gesetzt"
}
o, err := b.store.SetStatus(id, status, note, userID(i), userString(i))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
b.replyEphemeral(s, i, "Auftrag nicht gefunden.")
} else {
b.replyEphemeral(s, i, err.Error())
}
return
}
active := status == StatusOpen
b.updateOrderMessages(o, active)
b.notifyParticipants(o, fmt.Sprintf("Status von Auftrag #%d wurde auf `%s` gesetzt. Hinweis: %s", id, statusLabel(status), note))
b.audit(fmt.Sprintf("Auftrag #%d Status %s durch %s: %s", id, statusLabel(status), userString(i), note))
b.replyEphemeral(s, i, fmt.Sprintf("Status von Auftrag #%d wurde auf %s gesetzt.", id, statusLabel(status)))
}
func (b *Bot) cmdCancel(s *discordgo.Session, i *discordgo.InteractionCreate, id int64, reason string) {
o, err := b.store.GetOrder(id)
if err != nil {
b.replyEphemeral(s, i, "Auftrag nicht gefunden.")
return
}
if userID(i) != o.SubmitterID && !b.isTeamOrAdmin(i) {
b.replyEphemeral(s, i, "Nur der Einreicher oder Team/Admin kann abbrechen.")
return
}
if reason == "" {
reason = "Abgebrochen"
}
o, err = b.store.SetStatus(id, StatusCancelled, reason, userID(i), userString(i))
if err != nil {
b.replyEphemeral(s, i, err.Error())
return
}
b.updateOrderMessages(o, false)
b.notifyParticipants(o, fmt.Sprintf("Auftrag #%d wurde abgebrochen. Grund: %s", id, reason))
b.audit(fmt.Sprintf("Auftrag #%d abgebrochen durch %s: %s", id, userString(i), reason))
b.replyEphemeral(s, i, fmt.Sprintf("Auftrag #%d wurde abgebrochen.", id))
}
func (b *Bot) cmdMessage(s *discordgo.Session, i *discordgo.InteractionCreate, id int64, text string) {
o, err := b.store.GetOrder(id)
if err != nil {
b.replyEphemeral(s, i, "Auftrag nicht gefunden.")
return
}
uid := userID(i)
if uid != o.SubmitterID && uid != o.AcceptorID && !b.isTeamOrAdmin(i) {
b.replyEphemeral(s, i, "Nur Einreicher, Annehmer oder Team/Admin können Nachrichten senden.")
return
}
body := fmt.Sprintf("Nachricht zu Auftrag #%d von %s:\n%s", o.ID, userString(i), text)
b.notifyParticipants(o, body)
if o.ThreadID != "" {
_, _ = s.ChannelMessageSend(o.ThreadID, body)
}
_ = b.store.AddEvent(id, uid, userString(i), EventMessageForwarded, text)
b.replyEphemeral(s, i, "Nachricht wurde weitergeleitet.")
}
func (b *Bot) cmdInventoryAdd(s *discordgo.Session, i *discordgo.InteractionCreate, remove bool) {
if !b.isAdmin(i) {
b.replyEphemeral(s, i, "Nur Admins dürfen Lagerbestände ändern.")
return
}
amount := optFloat(i, "menge")
if remove {
amount = -amount
}
mode := optString(i, "modus")
item := InventoryItem{Name: strings.TrimSpace(optString(i, "ware")), Quality: int(optInt(i, "qualitaet")), QuantityAmount: amount, QuantityUnit: optString(i, "einheit"), Location: strings.TrimSpace(optString(i, "ort")), Note: strings.TrimSpace(optString(i, "notiz")), UpdatedByID: userID(i), UpdatedByName: userString(i)}
if item.Name == "" {
b.replyEphemeral(s, i, "Ware darf nicht leer sein.")
return
}
if item.Location == "" {
b.replyEphemeral(s, i, "Lagerort ist ein Pflichtfeld.")
return
}
exists, err := b.store.StorageLocationExists(item.Location)
if err != nil {
b.replyEphemeral(s, i, "Lagerort konnte nicht geprüft werden: "+err.Error())
return
}
if !exists {
b.replyEphemeral(s, i, "Lagerort existiert nicht. Bitte zuerst mit /lagerort_add anlegen.")
return
}
oldAmount := 0.0
if existing, err := b.store.GetInventoryByKey(normalizeName(item.Name), item.Quality, item.QuantityUnit, item.Location); err == nil {
oldAmount = existing.QuantityAmount
} else if !errors.Is(err, sql.ErrNoRows) {
b.replyEphemeral(s, i, "Lager konnte vor Änderung nicht gelesen werden: "+err.Error())
return
}
updated, err := b.store.UpsertInventory(item, mode != "set" || remove)
if err != nil {
b.replyEphemeral(s, i, "Lager konnte nicht aktualisiert werden: "+err.Error())
return
}
b.audit(fmt.Sprintf(
"Lager geändert durch %s: %s Q%d %s%s: %s → %s",
userString(i),
updated.Name,
updated.Quality,
updated.QuantityUnit,
locationSuffix(updated.Location),
formatAmount(oldAmount),
formatAmount(updated.QuantityAmount),
))
b.replyEphemeral(s, i, fmt.Sprintf("Lager aktualisiert: %s Q%d — %s %s%s", updated.Name, updated.Quality, formatAmount(updated.QuantityAmount), updated.QuantityUnit, locationSuffix(updated.Location)))
}
func (b *Bot) cmdInventoryList(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !b.isTeamOrAdmin(i) {
b.replyEphemeral(s, i, "Nur Team/Admin kann das Lager anzeigen.")
return
}
items, err := b.store.ListInventory(optString(i, "suche"), int(optIntDefault(i, "limit", 20)))
if err != nil {
b.replyEphemeral(s, i, err.Error())
return
}
if len(items) == 0 {
b.replyEphemeral(s, i, "Keine Lagerbestände gefunden.")
return
}
b.replyEphemeral(s, i, "Internes Lager\n"+inventoryTable(items))
}
func (b *Bot) cmdInventoryCheck(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !b.isTeamOrAdmin(i) {
b.replyEphemeral(s, i, "Nur Team/Admin kann das Lager prüfen.")
return
}
o := &Order{Commodity: optString(i, "ware"), Quality: int(optInt(i, "qualitaet")), QuantityAmount: optFloat(i, "menge"), QuantityUnit: optString(i, "einheit")}
b.replyEphemeral(s, i, b.inventoryText(o))
}
func (b *Bot) cmdStorageLocationAdd(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !b.isTeamOrAdmin(i) {
b.replyEphemeral(s, i, "Nur Team/Admin kann Lagerorte anlegen.")
return
}
name := strings.TrimSpace(optString(i, "name"))
if name == "" {
b.replyEphemeral(s, i, "Lagerort darf nicht leer sein.")
return
}
loc, err := b.store.CreateStorageLocation(name, userID(i), userString(i))
if err != nil {
b.replyEphemeral(s, i, "Lagerort konnte nicht angelegt werden: "+err.Error())
return
}
b.audit(fmt.Sprintf("Lagerort angelegt durch %s: %s", userString(i), loc.Name))
b.replyEphemeral(s, i, fmt.Sprintf("Lagerort angelegt: %s", loc.Name))
}
func (b *Bot) cmdStorageLocationRemove(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !b.isTeamOrAdmin(i) {
b.replyEphemeral(s, i, "Nur Team/Admin kann Lagerorte löschen.")
return
}
name := strings.TrimSpace(optString(i, "ort"))
if name == "" {
b.replyEphemeral(s, i, "Lagerort darf nicht leer sein.")
return
}
loc, err := b.store.DeleteStorageLocationIfEmpty(name)
if err != nil {
b.replyEphemeral(s, i, "Lagerort konnte nicht gelöscht werden: "+err.Error())
return
}
b.audit(fmt.Sprintf("Lagerort gelöscht durch %s: %s", userString(i), loc.Name))
b.replyEphemeral(s, i, fmt.Sprintf("Lagerort gelöscht: %s", loc.Name))
}
func (b *Bot) handleAutocomplete(s *discordgo.Session, i *discordgo.InteractionCreate) {
cmd := i.ApplicationCommandData().Name
focusedName, focusedValue := focusedOption(i)
if focusedName == "ort" && (cmd == "lager_add" || cmd == "lager_remove" || cmd == "lagerort_remove") {
if !b.isTeamOrAdmin(i) {
b.respondAutocomplete(s, i, nil)
return
}
locations, err := b.store.ListStorageLocations(focusedValue, 25)
if err != nil {
log.Println("location autocomplete failed:", err)
b.respondAutocomplete(s, i, nil)
return
}
choices := make([]*discordgo.ApplicationCommandOptionChoice, 0, len(locations))
for _, loc := range locations {
choices = append(choices, &discordgo.ApplicationCommandOptionChoice{Name: loc.Name, Value: loc.Name})
}
b.respondAutocomplete(s, i, choices)
return
}
if cmd == "trading_add" && (focusedName == "schiff" || focusedName == "ware") {
if !b.canUseTrading(i) {
b.respondAutocomplete(s, i, nil)
return
}
if focusedName == "schiff" {
b.respondAutocomplete(s, i, autocompleteFromList(b.cfg.TradingShips, focusedValue, 25))
return
}
b.respondAutocomplete(s, i, autocompleteFromList(b.cfg.TradingCommodities, focusedValue, 25))
return
}
b.respondAutocomplete(s, i, nil)
}
func (b *Bot) respondAutocomplete(s *discordgo.Session, i *discordgo.InteractionCreate, choices []*discordgo.ApplicationCommandOptionChoice) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionApplicationCommandAutocompleteResult,
Data: &discordgo.InteractionResponseData{Choices: choices},
})
}
func focusedOption(i *discordgo.InteractionCreate) (string, string) {
for _, opt := range i.ApplicationCommandData().Options {
if opt.Focused {
return opt.Name, opt.StringValue()
}
}
return "", ""
}
func (b *Bot) handleButton(s *discordgo.Session, i *discordgo.InteractionCreate) {
customID := i.MessageComponentData().CustomID
parts := strings.Split(customID, ":")
if len(parts) != 2 || (parts[0] != "order_accept" && parts[0] != "order_decline") {
return
}
if !b.isTeamOrAdmin(i) {
b.replyEphemeral(s, i, "Nur das Team kann Aufträge annehmen oder ablehnen.")
return
}
id, _ := strconv.ParseInt(parts[1], 10, 64)
user := i.Member.User
if parts[0] == "order_accept" {
o, err := b.store.AcceptOrder(id, user.ID, user.String())
if err != nil {
b.replyEphemeral(s, i, "Konnte nicht annehmen: "+err.Error())
return
}
b.dm(o.SubmitterID, fmt.Sprintf("Dein Auftrag #%d wurde von %s angenommen. Kontakt: <@%s>", o.ID, user.String(), user.ID))
if o.ThreadID != "" {
_, _ = s.ChannelMessageSend(o.ThreadID, fmt.Sprintf("Angenommen durch <@%s>.", user.ID))
}
b.updateOrderMessages(o, false)
b.audit(fmt.Sprintf("Auftrag #%d angenommen durch %s", id, user.String()))
b.replyEphemeral(s, i, fmt.Sprintf("Du hast Auftrag #%d angenommen. Der Einreicher wurde informiert.", id))
return
}
o, err := b.store.DeclineOrder(id, user.ID, user.String(), "Kann nicht angenommen werden")
if err != nil {
b.replyEphemeral(s, i, err.Error())
return
}
b.dm(o.SubmitterID, fmt.Sprintf("Auftrag #%d kann aktuell nicht angenommen werden.", o.ID))
b.updateOrderMessages(o, false)
b.audit(fmt.Sprintf("Auftrag #%d abgelehnt durch %s", id, user.String()))
b.replyEphemeral(s, i, "Ablehnung wurde an den Einreicher gemeldet.")
}
func (b *Bot) updateOrderMessages(o *Order, active bool) {
if o.PublicChannelID != "" && o.PublicMessageID != "" {
_, _ = b.dg.ChannelMessageEditComplex(&discordgo.MessageEdit{Channel: o.PublicChannelID, ID: o.PublicMessageID, Embeds: &[]*discordgo.MessageEmbed{publicOrderEmbed(o)}})
}
if o.InternalChannelID != "" && o.InternalMessageID != "" {
components := orderButtons(o.ID, active)
_, _ = b.dg.ChannelMessageEditComplex(&discordgo.MessageEdit{Channel: o.InternalChannelID, ID: o.InternalMessageID, Embeds: &[]*discordgo.MessageEmbed{b.internalOrderEmbed(o)}, Components: &components})
}
}
func orderButtons(id int64, active bool) []discordgo.MessageComponent {
if !active {
return []discordgo.MessageComponent{}
}
return []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{discordgo.Button{Label: "Annehmen", Style: discordgo.SuccessButton, CustomID: fmt.Sprintf("order_accept:%d", id)}, discordgo.Button{Label: "Ablehnen", Style: discordgo.DangerButton, CustomID: fmt.Sprintf("order_decline:%d", id)}}}}
}
func publicOrderEmbed(o *Order) *discordgo.MessageEmbed {
return &discordgo.MessageEmbed{Title: fmt.Sprintf("Auftrag #%d — %s", o.ID, o.Commodity), Description: "Ein neuer Lieferauftrag wurde erfasst.", Color: statusColor(o.Status), Fields: []*discordgo.MessageEmbedField{{Name: "Ware", Value: o.Commodity, Inline: true}, {Name: "Qualität", Value: fmt.Sprintf("%d/1000", o.Quality), Inline: true}, {Name: "Menge", Value: quantityString(o.QuantityAmount, o.QuantityUnit), Inline: true}, {Name: "Frist", Value: o.Deadline, Inline: true}, {Name: "Liefer-Ort", Value: o.DeliveryPlace, Inline: true}, {Name: "Budget", Value: fmt.Sprintf("%d aUEC", o.MaxBudgetAUEC), Inline: true}, {Name: "Status", Value: statusLabel(o.Status), Inline: true}}, Timestamp: o.UpdatedAt.Format(time.RFC3339)}
}
func (b *Bot) internalOrderEmbed(o *Order) *discordgo.MessageEmbed {
emb := publicOrderEmbed(o)
emb.Title = "Intern: " + emb.Title
emb.Fields = append(emb.Fields, &discordgo.MessageEmbedField{Name: "Einreicher", Value: fmt.Sprintf("<@%s> (%s)", o.SubmitterID, o.SubmitterName), Inline: false})
if o.AcceptorID != "" {
emb.Fields = append(emb.Fields, &discordgo.MessageEmbedField{Name: "Angenommen von", Value: fmt.Sprintf("<@%s> (%s)", o.AcceptorID, o.AcceptorName), Inline: false})
}
emb.Fields = append(emb.Fields, &discordgo.MessageEmbedField{Name: "Interne Lagerprüfung", Value: b.inventoryText(o), Inline: false})
if o.ThreadID != "" {
emb.Fields = append(emb.Fields, &discordgo.MessageEmbedField{Name: "Interner Thread", Value: fmt.Sprintf("<#%s>", o.ThreadID), Inline: false})
}
return emb
}
func (b *Bot) inventoryText(o *Order) string {
matches, total, err := b.store.FindInventoryForOrder(o)
if err != nil {
return "Lagerprüfung fehlgeschlagen: " + err.Error()
}
if len(matches) == 0 {
return fmt.Sprintf("Nicht verfügbar: kein Bestand für `%s` mit Qualität >= %d und Einheit `%s`.", o.Commodity, o.Quality, o.QuantityUnit)
}
status := "Teilweise/nicht ausreichend"
if total >= o.QuantityAmount {
status = "Verfügbar"
}
var lines []string
lines = append(lines, fmt.Sprintf("%s: benötigt %s %s, gesamt verfügbar %s %s.", status, formatAmount(o.QuantityAmount), o.QuantityUnit, formatAmount(total), o.QuantityUnit))
for idx, m := range matches {
if idx >= 5 {
lines = append(lines, fmt.Sprintf("… plus %d weitere Positionen", len(matches)-idx))
break
}
lines = append(lines, fmt.Sprintf("• %s Q%d: %s %s%s", m.Item.Name, m.Item.Quality, formatAmount(m.Item.QuantityAmount), m.Item.QuantityUnit, locationSuffix(m.Item.Location)))
}
return strings.Join(lines, "\n")
}
func statusLabel(status string) string {
switch status {
case StatusOpen:
return "offen"
case StatusAccepted:
return "angenommen"
case StatusInDelivery:
return "in Lieferung"
case StatusDelivered:
return "geliefert"
case StatusCompleted:
return "abgeschlossen"
case StatusDeclined:
return "abgelehnt"
case StatusCancelled:
return "abgebrochen"
default:
return status
}
}
func statusColor(status string) int {
switch status {
case StatusOpen:
return 0x3498db
case StatusAccepted, StatusInDelivery:
return 0xf1c40f
case StatusDelivered, StatusCompleted:
return 0x2ecc71
case StatusDeclined, StatusCancelled:
return 0xe74c3c
default:
return 0x95a5a6
}
}
func (b *Bot) tryCreateThread(channelID, messageID, name string) string {
thread, err := b.dg.MessageThreadStart(channelID, messageID, name, 1440)
if err != nil {
log.Println("thread creation failed:", err)
return ""
}
return thread.ID
}
func (b *Bot) startThreadCleanup() {
if b.cfg.ThreadDeleteAfter <= 0 {
log.Println("Thread-Bereinigung ist deaktiviert.")
return
}
go func() {
b.cleanupClosedThreads()
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
b.cleanupClosedThreads()
case <-b.cleanupStop:
return
}
}
}()
}
func (b *Bot) cleanupClosedThreads() {
cutoff := time.Now().UTC().Add(-b.cfg.ThreadDeleteAfter)
orders, err := b.store.ListOrdersWithDeletableThreads(cutoff, 50)
if err != nil {
log.Println("thread cleanup query failed:", err)
return
}
for _, o := range orders {
if o.ThreadID == "" {
continue
}
if _, err := b.dg.ChannelDelete(o.ThreadID); err != nil {
log.Printf("thread cleanup failed for order #%d thread %s: %v", o.ID, o.ThreadID, err)
continue
}
if err := b.store.ClearThreadID(o.ID); err != nil {
log.Printf("thread cleanup db update failed for order #%d: %v", o.ID, err)
}
b.audit(fmt.Sprintf("Interner Thread zu Auftrag #%d wurde nach Archivierung gelöscht.", o.ID))
}
}
func (b *Bot) notifyParticipants(o *Order, body string) {
b.dm(o.SubmitterID, body)
if o.AcceptorID != "" && o.AcceptorID != o.SubmitterID {
b.dm(o.AcceptorID, body)
}
}
func (b *Bot) dm(userID, body string) {
if err := b.dmErr(userID, body); err != nil {
log.Println("dm send failed:", err)
}
}
func (b *Bot) dmErr(userID, body string) error {
ch, err := b.dg.UserChannelCreate(userID)
if err != nil {
return err
}
_, err = b.dg.ChannelMessageSend(ch.ID, body)
return err
}
func (b *Bot) audit(body string) {
log.Println("AUDIT", body)
if b.cfg.AuditChannelID != "" {
_, _ = b.dg.ChannelMessageSend(b.cfg.AuditChannelID, body)
}
}
func (b *Bot) isTeamOrAdmin(i *discordgo.InteractionCreate) bool {
return b.isAdmin(i) || hasAnyRole(i, b.cfg.TeamRoleIDs)
}
func (b *Bot) isAdmin(i *discordgo.InteractionCreate) bool {
if hasAnyRole(i, b.cfg.AdminRoleIDs) {
return true
}
if i.Member != nil && i.Member.Permissions&discordgo.PermissionAdministrator != 0 {
return true
}
return false
}
func hasAnyRole(i *discordgo.InteractionCreate, roleIDs []string) bool {
if i.Member == nil {
return false
}
for _, have := range i.Member.Roles {
for _, want := range roleIDs {
if have == want {
return true
}
}
}
return false
}
func (b *Bot) replyEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, content string) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: content, Flags: discordgo.MessageFlagsEphemeral}})
}
func (b *Bot) respondEmbed(s *discordgo.Session, i *discordgo.InteractionCreate, embed *discordgo.MessageEmbed, ephemeral bool) {
flags := discordgo.MessageFlags(0)
if ephemeral {
flags = discordgo.MessageFlagsEphemeral
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Embeds: []*discordgo.MessageEmbed{embed}, Flags: flags}})
}
func optString(i *discordgo.InteractionCreate, name string) string {
for _, o := range i.ApplicationCommandData().Options {
if o.Name == name {
return o.StringValue()
}
}
return ""
}
func optInt(i *discordgo.InteractionCreate, name string) int64 {
for _, o := range i.ApplicationCommandData().Options {
if o.Name == name {
return o.IntValue()
}
}
return 0
}
func optIntDefault(i *discordgo.InteractionCreate, name string, fallback int64) int64 {
for _, o := range i.ApplicationCommandData().Options {
if o.Name == name {
return o.IntValue()
}
}
return fallback
}
func optFloat(i *discordgo.InteractionCreate, name string) float64 {
for _, o := range i.ApplicationCommandData().Options {
if o.Name == name {
return o.FloatValue()
}
}
return 0
}
func userID(i *discordgo.InteractionCreate) string {
if i.Member != nil && i.Member.User != nil {
return i.Member.User.ID
}
if i.User != nil {
return i.User.ID
}
return ""
}
func userString(i *discordgo.InteractionCreate) string {
if i.Member != nil && i.Member.User != nil {
return i.Member.User.String()
}
if i.User != nil {
return i.User.String()
}
return "unbekannt"
}
func locationSuffix(v string) string {
if strings.TrimSpace(v) == "" {
return ""
}
return " @ " + strings.TrimSpace(v)
}
func noteSuffix(v string) string {
if strings.TrimSpace(v) == "" {
return ""
}
return " — " + strings.TrimSpace(v)
}
func inventoryTable(items []InventoryItem) string {
const maxMessageLen = 1900
var b strings.Builder
b.WriteString("```text\n")
b.WriteString(fmt.Sprintf("%-4s %-22s %-5s %-10s %-8s %-24s\n", "ID", "Ware", "Q", "Menge", "Einheit", "Ort"))
b.WriteString(strings.Repeat("-", 78))
b.WriteString("\n")
shown := 0
for _, it := range items {
name := truncateText(it.Name, 22)
location := truncateText(strings.TrimSpace(it.Location), 24)
if location == "" {
location = "-"
}
row := fmt.Sprintf(
"%-4d %-22s %-5d %-10s %-8s %-24s\n",
it.ID,
name,
it.Quality,
formatAmount(it.QuantityAmount),
it.QuantityUnit,
location,
)
if strings.TrimSpace(it.Note) != "" {
row += fmt.Sprintf(" Notiz: %s\n", truncateText(it.Note, 70))
}
// Discord-Nachrichten sind auf 2000 Zeichen begrenzt. Bei langen Listen
// brechen wir sauber ab und schließen den Codeblock trotzdem korrekt.
remainingHint := fmt.Sprintf("… plus %d weitere Positionen\n", len(items)-shown)
if b.Len()+len(row)+len(remainingHint)+len("```") > maxMessageLen {
if len(items)-shown > 0 {
b.WriteString(remainingHint)
}
break
}
b.WriteString(row)
shown++
}
b.WriteString("```")
return b.String()
}
func truncateText(v string, max int) string {
v = strings.TrimSpace(v)
if max <= 0 {
return ""
}
runes := []rune(v)
if len(runes) <= max {
return v
}
if max == 1 {
return string(runes[:1])
}
return string(runes[:max-1]) + "…"
}
func safeThreadName(v string) string {
v = strings.Map(func(r rune) rune {
if strings.ContainsRune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_", r) {
return r
}
if r == ' ' {
return '-'
}
return -1
}, v)
if len(v) > 35 {
v = v[:35]
}
if v == "" {
return "auftrag"
}
return v
}