diff --git a/.env.example b/.env.example index 4ef4ee2..81d109a 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,5 @@ DISCORD_ADMIN_ROLE_IDS=123456789012345678,234567890123456789 # 15098193051880039 DISCORD_TEAM_ROLE_IDS=345678901234567890 # 1509819434330755233 DATABASE_PATH=/data/orders.db + +THREAD_DELETE_AFTER_HOURS=72 diff --git a/README.md b/README.md index c6056b4..6bfaea5 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ DISCORD_AUDIT_CHANNEL_ID=... DISCORD_ADMIN_ROLE_IDS=roleId1,roleId2 DISCORD_TEAM_ROLE_IDS=roleId3,roleId4 DATABASE_PATH=orders.db +THREAD_DELETE_AFTER_HOURS=72 ``` `DISCORD_INTERNAL_ORDER_CHANNEL_ID` sollte ein privater Team-Channel sein. Nur dort erscheinen Lagerprüfung und Annahme-/Ablehnen-Buttons. @@ -58,7 +59,7 @@ Erstellt einen Auftrag mit: - `ware` - `qualitaet` 0-1000 - `menge` als Zahl -- `einheit` `SCU` oder `Stück` +- `einheit` `SCU`, `cSCU` oder `Stück` - `frist` im Format `YYYY-MM-DD` - `lieferort` - `budget` in aUEC @@ -69,11 +70,11 @@ Zeigt den Auftrag. Team/Admin sieht die interne Ansicht inklusive Lagerprüfung, ### `/auftrag_liste status: limit:` -Listet nur aktive Aufträge. Standardmäßig werden `open`, `accepted`, `in_delivery` und `delivered` angezeigt. Abgeschlossene, abgelehnte und abgebrochene Aufträge erscheinen hier nicht mehr. +Listet nur aktive Aufträge. Standardmäßig werden `offen`, `angenommen`, `in Lieferung` und `geliefert` angezeigt. Abgeschlossene, abgelehnte und abgebrochene Aufträge erscheinen hier nicht mehr. ### `/auftrag_archiv status: limit:` -Listet archivierte Aufträge mit den Statuswerten `completed`, `declined` und `cancelled`. Team/Admin sieht zusätzlich interne Zuordnungen. +Listet archivierte Aufträge mit den deutschen Statuswerten `abgeschlossen`, `abgelehnt` und `abgebrochen`. Intern werden die stabilen Werte `completed`, `declined` und `cancelled` gespeichert. ### `/auftrag_abschliessen id:` @@ -93,13 +94,13 @@ Leitet eine Nachricht per DM an Einreicher und Annehmer weiter und schreibt sie Team/Admin kann den Status manuell setzen: -- `open` -- `accepted` -- `in_delivery` -- `delivered` -- `completed` -- `declined` -- `cancelled` +- `offen` +- `angenommen` +- `in Lieferung` +- `geliefert` +- `abgeschlossen` +- `abgelehnt` +- `abgebrochen` ## Lager Commands @@ -146,14 +147,17 @@ Prüft intern, ob eine Menge verfügbar ist. ```text /lager_check ware:Gold qualitaet:900 menge:64 einheit:SCU +/lager_check ware:P4-AR qualitaet:725 menge:2 einheit:cSCU ``` ## Statusmodell ```text -open -> accepted -> in_delivery -> delivered -> completed - \-> cancelled -open -> declined +offen -> angenommen -> in Lieferung -> geliefert -> abgeschlossen + \-> abgebrochen +offen -> abgelehnt + +Intern speichert der Bot weiterhin stabile technische Statuswerte wie `open`, `accepted`, `completed` usw. In Discord werden sie deutsch angezeigt. ``` ## Datenbanktabellen @@ -180,3 +184,46 @@ Wenn ein Lagerbestand durch `/lager_remove` oder `/lager_add` mit `modus:setzen` Zusätzlich verhindert die Lagerlogik negative Bestände. Wenn mehr entfernt wird als vorhanden ist, antwortet der Bot mit einem Fehler und ändert den Bestand nicht. `/lager_liste` und die interne Lagerprüfung berücksichtigen nur Bestände größer als `0`, sodass alte Nullbestände nicht mehr angezeigt werden. + + +## Automatische Thread-Bereinigung + +Interne Auftragsthreads werden nach einer konfigurierbaren Zeit automatisch gelöscht, sobald der Auftrag archiviert ist. Archiviert bedeutet: + +- `abgeschlossen` +- `abgelehnt` +- `abgebrochen` + +Die Zeit steuerst du über: + +```env +THREAD_DELETE_AFTER_HOURS=72 +``` + +`72` bedeutet: Der Thread wird frühestens 72 Stunden nach der letzten Statusänderung gelöscht. `0` deaktiviert die automatische Thread-Bereinigung. + +Für das Löschen benötigt der Bot im internen Channel zusätzlich `Manage Threads`. Wenn diese Berechtigung fehlt, bleibt der Thread bestehen und der Fehler erscheint im Bot-Log. + +## Deutsche Statusbegriffe + +Discord zeigt Statuswerte jetzt deutsch an: + +| Anzeige | interner Wert | +| --- | --- | +| offen | `open` | +| angenommen | `accepted` | +| in Lieferung | `in_delivery` | +| geliefert | `delivered` | +| abgeschlossen | `completed` | +| abgelehnt | `declined` | +| abgebrochen | `cancelled` | + +Die internen Werte bleiben absichtlich englisch, damit bestehende Datenbanken und Integrationen stabil bleiben. + +## Einheiten + +Bei Aufträgen und Lagerbeständen sind jetzt diese Einheiten auswählbar: + +- `SCU` +- `cSCU` +- `Stück` diff --git a/config.go b/config.go index bf05d36..145a688 100644 --- a/config.go +++ b/config.go @@ -3,7 +3,9 @@ package main import ( "log" "os" + "strconv" "strings" + "time" ) type Config struct { @@ -16,6 +18,7 @@ type Config struct { AdminRoleIDs []string TeamRoleIDs []string DatabasePath string + ThreadDeleteAfter time.Duration } func loadConfig() Config { @@ -29,6 +32,7 @@ func loadConfig() Config { AdminRoleIDs: splitCSV(os.Getenv("DISCORD_ADMIN_ROLE_IDS")), TeamRoleIDs: splitCSV(os.Getenv("DISCORD_TEAM_ROLE_IDS")), DatabasePath: getenv("DATABASE_PATH", "orders.db"), + ThreadDeleteAfter: durationHoursEnv("THREAD_DELETE_AFTER_HOURS", 72), } if cfg.Token == "" || cfg.AppID == "" || cfg.InternalOrderChannelID == "" { log.Fatal("Bitte DISCORD_TOKEN, DISCORD_APP_ID und DISCORD_INTERNAL_ORDER_CHANNEL_ID setzen") @@ -56,3 +60,16 @@ func splitCSV(v string) []string { } return out } + +func durationHoursEnv(key string, fallbackHours int) time.Duration { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return time.Duration(fallbackHours) * time.Hour + } + hours, err := strconv.Atoi(v) + if err != nil || hours < 0 { + log.Printf("WARNUNG: %s ist ungültig, verwende %d Stunden", key, fallbackHours) + return time.Duration(fallbackHours) * time.Hour + } + return time.Duration(hours) * time.Hour +} diff --git a/db.go b/db.go index b728f94..7b2d3ee 100644 --- a/db.go +++ b/db.go @@ -221,6 +221,31 @@ func (s *Store) listOrders(status string, limit int, archived bool) ([]Order, er return out, rows.Err() } +func (s *Store) ListOrdersWithDeletableThreads(cutoff time.Time, limit int) ([]Order, error) { + if limit <= 0 || limit > 100 { + limit = 50 + } + rows, err := s.db.Query(`SELECT id, submitter_id, submitter_name, acceptor_id, acceptor_name, commodity, quality, quantity_amount, quantity_unit, deadline, delivery_place, max_budget_auec, status, public_channel_id, public_message_id, internal_channel_id, internal_message_id, thread_id, created_at, updated_at, last_feedback FROM orders WHERE thread_id <> '' AND status IN (?, ?, ?) AND updated_at <= ? ORDER BY updated_at ASC LIMIT ?`, StatusCompleted, StatusDeclined, StatusCancelled, cutoff.UTC(), limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Order + for rows.Next() { + o, err := scanOrder(rows) + if err != nil { + return nil, err + } + out = append(out, *o) + } + return out, rows.Err() +} + +func (s *Store) ClearThreadID(orderID int64) error { + _, err := s.db.Exec(`UPDATE orders SET thread_id='', updated_at=? WHERE id=?`, time.Now().UTC(), orderID) + return err +} + func (s *Store) AcceptOrder(id int64, userID, userName string) (*Order, error) { tx, err := s.db.Begin() if err != nil { @@ -281,7 +306,7 @@ func (s *Store) SetStatus(id int64, status, feedback, actorID, actorName string) if n == 0 { return nil, sql.ErrNoRows } - if err := addEventTx(tx, id, actorID, actorName, EventOrderStatus, fmt.Sprintf("Status auf %s gesetzt: %s", status, feedback)); err != nil { + if err := addEventTx(tx, id, actorID, actorName, EventOrderStatus, fmt.Sprintf("Status auf %s gesetzt: %s", statusLabel(status), feedback)); err != nil { return nil, err } if err := tx.Commit(); err != nil { diff --git a/discord.go b/discord.go index 739890f..cad8bcd 100644 --- a/discord.go +++ b/discord.go @@ -13,9 +13,10 @@ import ( ) type Bot struct { - cfg Config - store *Store - dg *discordgo.Session + cfg Config + store *Store + dg *discordgo.Session + cleanupStop chan struct{} } func NewBot(cfg Config, store *Store) (*Bot, error) { @@ -24,7 +25,7 @@ func NewBot(cfg Config, store *Store) (*Bot, error) { return nil, err } dg.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsDirectMessages - b := &Bot{cfg: cfg, store: store, dg: dg} + b := &Bot{cfg: cfg, store: store, dg: dg, cleanupStop: make(chan struct{})} dg.AddHandler(b.onReady) dg.AddHandler(b.onInteraction) return b, nil @@ -34,10 +35,17 @@ func (b *Bot) Start() error { if err := b.dg.Open(); err != nil { return err } - return b.registerCommands() + if err := b.registerCommands(); err != nil { + return err + } + b.startThreadCleanup() + return nil } -func (b *Bot) Stop() { _ = b.dg.Close() } +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()) @@ -51,7 +59,7 @@ func (b *Bot) registerCommands() error { {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 für Commodities, Stück für Items", Type: discordgo.ApplicationCommandOptionString, Required: true, Choices: []*discordgo.ApplicationCommandOptionChoice{{Name: "SCU", Value: "SCU"}, {Name: "Stück", Value: "Stück"}}}, + {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)}, @@ -64,10 +72,10 @@ func (b *Bot) registerCommands() error { {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 oder Stück", Type: discordgo.ApplicationCommandOptionString, Required: true, Choices: []*discordgo.ApplicationCommandOptionChoice{{Name: "SCU", Value: "SCU"}, {Name: "Stück", Value: "Stück"}}}, {Name: "ort", Description: "Lagerort", Type: discordgo.ApplicationCommandOptionString, Required: false}, {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 oder Stück", Type: discordgo.ApplicationCommandOptionString, Required: true, Choices: []*discordgo.ApplicationCommandOptionChoice{{Name: "SCU", Value: "SCU"}, {Name: "Stück", Value: "Stück"}}}, {Name: "ort", Description: "Lagerort", Type: discordgo.ApplicationCommandOptionString, Required: false}, {Name: "notiz", Description: "Interne Notiz", Type: discordgo.ApplicationCommandOptionString, Required: false}}}, + {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: false}, {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: false}, {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 oder Stück", Type: discordgo.ApplicationCommandOptionString, Required: true, Choices: []*discordgo.ApplicationCommandOptionChoice{{Name: "SCU", Value: "SCU"}, {Name: "Stück", Value: "Stück"}}}}}, + {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()}}}, } for _, cmd := range commands { if _, err := b.dg.ApplicationCommandCreate(b.cfg.AppID, b.cfg.GuildID, cmd); err != nil { @@ -78,16 +86,42 @@ func (b *Bot) registerCommands() error { } 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: StatusOpen, Value: StatusOpen}, {Name: StatusAccepted, Value: StatusAccepted}, {Name: StatusInDelivery, Value: StatusInDelivery}, {Name: StatusDelivered, Value: StatusDelivered}, {Name: StatusCompleted, Value: StatusCompleted}, {Name: StatusDeclined, Value: StatusDeclined}, {Name: StatusCancelled, Value: StatusCancelled}} + 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: StatusOpen, Value: StatusOpen}, {Name: StatusAccepted, Value: StatusAccepted}, {Name: StatusInDelivery, Value: StatusInDelivery}, {Name: StatusDelivered, Value: StatusDelivered}} + 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: StatusCompleted, Value: StatusCompleted}, {Name: StatusDeclined, Value: StatusDeclined}, {Name: StatusCancelled, Value: StatusCancelled}} + return []*discordgo.ApplicationCommandOptionChoice{ + {Name: statusLabel(StatusCompleted), Value: StatusCompleted}, + {Name: statusLabel(StatusDeclined), Value: StatusDeclined}, + {Name: statusLabel(StatusCancelled), Value: StatusCancelled}, + } } func isArchivedStatus(status string) bool { @@ -214,7 +248,7 @@ func (b *Bot) cmdListOrders(s *discordgo.Session, i *discordgo.InteractionCreate if status != "" && isArchivedStatus(status) != archived { if archived { - b.replyEphemeral(s, i, "`/auftrag_archiv` zeigt nur completed, declined und cancelled. Aktive Aufträge findest du mit `/auftrag_liste`.") + 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`.") } @@ -244,7 +278,7 @@ func (b *Bot) cmdListOrders(s *discordgo.Session, i *discordgo.InteractionCreate } 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, o.Status) + 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) } @@ -294,9 +328,9 @@ func (b *Bot) cmdSetStatus(s *discordgo.Session, i *discordgo.InteractionCreate, } active := status == StatusOpen b.updateOrderMessages(o, active) - b.notifyParticipants(o, fmt.Sprintf("Status von Auftrag #%d wurde auf `%s` gesetzt. Hinweis: %s", id, status, note)) - b.audit(fmt.Sprintf("Auftrag #%d Status %s durch %s: %s", id, status, userString(i), note)) - b.replyEphemeral(s, i, fmt.Sprintf("Status von Auftrag #%d wurde auf %s gesetzt.", id, status)) + 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) { @@ -466,7 +500,7 @@ func orderButtons(id int64, active bool) []discordgo.MessageComponent { } 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: o.Status, Inline: true}}, Timestamp: o.UpdatedAt.Format(time.RFC3339)} + 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 { @@ -507,6 +541,27 @@ func (b *Bot) inventoryText(o *Order) string { 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: @@ -531,6 +586,48 @@ func (b *Bot) tryCreateThread(channelID, messageID, name string) string { 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 {