Deutsch und Cleanup der Kanäle
release-tag / release-image (push) Successful in 2m8s

This commit is contained in:
2026-05-29 21:48:19 +02:00
parent 4d49c0af83
commit 720708f9d3
5 changed files with 221 additions and 33 deletions
+2
View File
@@ -22,3 +22,5 @@ DISCORD_ADMIN_ROLE_IDS=123456789012345678,234567890123456789 # 15098193051880039
DISCORD_TEAM_ROLE_IDS=345678901234567890 # 1509819434330755233 DISCORD_TEAM_ROLE_IDS=345678901234567890 # 1509819434330755233
DATABASE_PATH=/data/orders.db DATABASE_PATH=/data/orders.db
THREAD_DELETE_AFTER_HOURS=72
+60 -13
View File
@@ -45,6 +45,7 @@ DISCORD_AUDIT_CHANNEL_ID=...
DISCORD_ADMIN_ROLE_IDS=roleId1,roleId2 DISCORD_ADMIN_ROLE_IDS=roleId1,roleId2
DISCORD_TEAM_ROLE_IDS=roleId3,roleId4 DISCORD_TEAM_ROLE_IDS=roleId3,roleId4
DATABASE_PATH=orders.db 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. `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` - `ware`
- `qualitaet` 0-1000 - `qualitaet` 0-1000
- `menge` als Zahl - `menge` als Zahl
- `einheit` `SCU` oder `Stück` - `einheit` `SCU`, `cSCU` oder `Stück`
- `frist` im Format `YYYY-MM-DD` - `frist` im Format `YYYY-MM-DD`
- `lieferort` - `lieferort`
- `budget` in aUEC - `budget` in aUEC
@@ -69,11 +70,11 @@ Zeigt den Auftrag. Team/Admin sieht die interne Ansicht inklusive Lagerprüfung,
### `/auftrag_liste status:<optional> limit:<optional>` ### `/auftrag_liste status:<optional> limit:<optional>`
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:<optional> limit:<optional>` ### `/auftrag_archiv status:<optional> limit:<optional>`
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:<id>` ### `/auftrag_abschliessen id:<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: Team/Admin kann den Status manuell setzen:
- `open` - `offen`
- `accepted` - `angenommen`
- `in_delivery` - `in Lieferung`
- `delivered` - `geliefert`
- `completed` - `abgeschlossen`
- `declined` - `abgelehnt`
- `cancelled` - `abgebrochen`
## Lager Commands ## Lager Commands
@@ -146,14 +147,17 @@ Prüft intern, ob eine Menge verfügbar ist.
```text ```text
/lager_check ware:Gold qualitaet:900 menge:64 einheit:SCU /lager_check ware:Gold qualitaet:900 menge:64 einheit:SCU
/lager_check ware:P4-AR qualitaet:725 menge:2 einheit:cSCU
``` ```
## Statusmodell ## Statusmodell
```text ```text
open -> accepted -> in_delivery -> delivered -> completed offen -> angenommen -> in Lieferung -> geliefert -> abgeschlossen
\-> cancelled \-> abgebrochen
open -> declined offen -> abgelehnt
Intern speichert der Bot weiterhin stabile technische Statuswerte wie `open`, `accepted`, `completed` usw. In Discord werden sie deutsch angezeigt.
``` ```
## Datenbanktabellen ## 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. 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. `/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`
+17
View File
@@ -3,7 +3,9 @@ package main
import ( import (
"log" "log"
"os" "os"
"strconv"
"strings" "strings"
"time"
) )
type Config struct { type Config struct {
@@ -16,6 +18,7 @@ type Config struct {
AdminRoleIDs []string AdminRoleIDs []string
TeamRoleIDs []string TeamRoleIDs []string
DatabasePath string DatabasePath string
ThreadDeleteAfter time.Duration
} }
func loadConfig() Config { func loadConfig() Config {
@@ -29,6 +32,7 @@ func loadConfig() Config {
AdminRoleIDs: splitCSV(os.Getenv("DISCORD_ADMIN_ROLE_IDS")), AdminRoleIDs: splitCSV(os.Getenv("DISCORD_ADMIN_ROLE_IDS")),
TeamRoleIDs: splitCSV(os.Getenv("DISCORD_TEAM_ROLE_IDS")), TeamRoleIDs: splitCSV(os.Getenv("DISCORD_TEAM_ROLE_IDS")),
DatabasePath: getenv("DATABASE_PATH", "orders.db"), DatabasePath: getenv("DATABASE_PATH", "orders.db"),
ThreadDeleteAfter: durationHoursEnv("THREAD_DELETE_AFTER_HOURS", 72),
} }
if cfg.Token == "" || cfg.AppID == "" || cfg.InternalOrderChannelID == "" { if cfg.Token == "" || cfg.AppID == "" || cfg.InternalOrderChannelID == "" {
log.Fatal("Bitte DISCORD_TOKEN, DISCORD_APP_ID und DISCORD_INTERNAL_ORDER_CHANNEL_ID setzen") 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 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
}
+26 -1
View File
@@ -221,6 +221,31 @@ func (s *Store) listOrders(status string, limit int, archived bool) ([]Order, er
return out, rows.Err() 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) { func (s *Store) AcceptOrder(id int64, userID, userName string) (*Order, error) {
tx, err := s.db.Begin() tx, err := s.db.Begin()
if err != nil { if err != nil {
@@ -281,7 +306,7 @@ func (s *Store) SetStatus(id int64, status, feedback, actorID, actorName string)
if n == 0 { if n == 0 {
return nil, sql.ErrNoRows 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 return nil, err
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
+116 -19
View File
@@ -13,9 +13,10 @@ import (
) )
type Bot struct { type Bot struct {
cfg Config cfg Config
store *Store store *Store
dg *discordgo.Session dg *discordgo.Session
cleanupStop chan struct{}
} }
func NewBot(cfg Config, store *Store) (*Bot, error) { func NewBot(cfg Config, store *Store) (*Bot, error) {
@@ -24,7 +25,7 @@ func NewBot(cfg Config, store *Store) (*Bot, error) {
return nil, err return nil, err
} }
dg.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsDirectMessages 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.onReady)
dg.AddHandler(b.onInteraction) dg.AddHandler(b.onInteraction)
return b, nil return b, nil
@@ -34,10 +35,17 @@ func (b *Bot) Start() error {
if err := b.dg.Open(); err != nil { if err := b.dg.Open(); err != nil {
return err 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) { func (b *Bot) onReady(s *discordgo.Session, r *discordgo.Ready) {
log.Println("Bot ist online als", r.User.String()) 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: "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: "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: "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: "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: "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: "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_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_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: "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_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 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_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_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 { for _, cmd := range commands {
if _, err := b.dg.ApplicationCommandCreate(b.cfg.AppID, b.cfg.GuildID, cmd); err != nil { 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 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 { 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 { 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 { 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 { 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 status != "" && isArchivedStatus(status) != archived {
if 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 { } else {
b.replyEphemeral(s, i, "`/auftrag_liste` zeigt nur aktive Aufträge. Abgeschlossene/abgelehnte/abgebrochene Aufträge findest du mit `/auftrag_archiv`.") 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 var lines []string
for _, o := range orders { 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) { if b.isTeamOrAdmin(i) {
line += fmt.Sprintf(", Einreicher <@%s>", o.SubmitterID) line += fmt.Sprintf(", Einreicher <@%s>", o.SubmitterID)
} }
@@ -294,9 +328,9 @@ func (b *Bot) cmdSetStatus(s *discordgo.Session, i *discordgo.InteractionCreate,
} }
active := status == StatusOpen active := status == StatusOpen
b.updateOrderMessages(o, active) b.updateOrderMessages(o, active)
b.notifyParticipants(o, fmt.Sprintf("Status von Auftrag #%d wurde auf `%s` gesetzt. Hinweis: %s", id, status, note)) 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, status, userString(i), 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, status)) 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) { 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 { 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 { func (b *Bot) internalOrderEmbed(o *Order) *discordgo.MessageEmbed {
@@ -507,6 +541,27 @@ func (b *Bot) inventoryText(o *Order) string {
return strings.Join(lines, "\n") 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 { func statusColor(status string) int {
switch status { switch status {
case StatusOpen: case StatusOpen:
@@ -531,6 +586,48 @@ func (b *Bot) tryCreateThread(channelID, messageID, name string) string {
return thread.ID 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) { func (b *Bot) notifyParticipants(o *Order, body string) {
b.dm(o.SubmitterID, body) b.dm(o.SubmitterID, body)
if o.AcceptorID != "" && o.AcceptorID != o.SubmitterID { if o.AcceptorID != "" && o.AcceptorID != o.SubmitterID {