All checks were successful
release-tag / release-image (push) Successful in 2m18s
963 lines
38 KiB
Go
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
|
|
}
|