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 }