diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b4d31ef --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Discord Developer Portal / Bot +DISCORD_TOKEN=BotTokenHere +DISCORD_APP_ID=ApplicationIDHere + +# Optional: set for fast slash-command registration during development. +# Empty = global commands, can take longer to propagate. +DISCORD_GUILD_ID=OptionalTestGuildIDForFastCommandRegistration + +# Public channel for sanitized order overview. Leave empty if only the internal team should see order posts. +DISCORD_PUBLIC_ORDER_CHANNEL_ID=PublicOrderChannelIDOptional + +# Private/internal channel for team operations. The bot posts buttons and warehouse checks here. +DISCORD_INTERNAL_ORDER_CHANNEL_ID=PrivateTeamOrderChannelIDRequired + +# Optional audit/log channel for admin traceability. +DISCORD_AUDIT_CHANNEL_ID=AuditLogChannelIDOptional + +# Comma-separated role IDs. Admin role can modify inventory and force status changes. +DISCORD_ADMIN_ROLE_IDS=123456789012345678,234567890123456789 + +# Comma-separated role IDs. Team role can see internal orders, accept/decline, check warehouse. +DISCORD_TEAM_ROLE_IDS=345678901234567890 + +DATABASE_PATH=orders.db diff --git a/.gitea/workflows/registry.yml b/.gitea/workflows/registry.yml new file mode 100644 index 0000000..3f0fca3 --- /dev/null +++ b/.gitea/workflows/registry.yml @@ -0,0 +1,51 @@ +name: release-tag +on: + push: + branches: + - 'main' +jobs: + release-image: + runs-on: ubuntu-latest + env: + DOCKER_ORG: stadthilden + DOCKER_LATEST: latest + RUNNER_TOOL_CACHE: /toolcache + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker BuildX + uses: docker/setup-buildx-action@v2 + with: # replace it with your local IP + config-inline: | + [registry."git.send.nrw"] + http = true + insecure = true + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + registry: git.send.nrw # replace it with your local IP + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get Meta + id: meta + run: | + echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT + echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + platforms: | + linux/amd64 + push: true + tags: | # replace it with your local IP and tags + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }} + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2eab462 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.26-alpine AS build +WORKDIR /src +COPY go.mod go.sum* ./ +RUN go mod download +COPY . . +RUN go build -o /out/starauftrag-bot . + +FROM alpine:3.22S +RUN adduser -D -H bot && mkdir /data && chown bot:bot /data +USER bot +WORKDIR /app +COPY --from=build /out/starauftrag-bot /app/starauftrag-bot +ENV DATABASE_PATH=/data/orders.db +VOLUME ["/data"] +ENTRYPOINT ["/app/starauftrag-bot"] diff --git a/README.md b/README.md index 2824ad0..e35d72f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,160 @@ -# sftrading +# Starauftrag Discord Bot (Go + SQLite) +Produktionsnaher Discord-Bot für Lieferaufträge mit SQLite-Persistenz, Slash Commands, internen Team-Buttons, Audit-Log und privater Lagerverwaltung. + +## Kernidee + +- Endnutzer erstellen mit `/auftrag` einen Lieferauftrag. +- Der öffentliche Auftragspost enthält nur auftragsbezogene Basisdaten. +- Das Team erhält in einem privaten internen Channel denselben Auftrag plus Lagerprüfung. +- Annahme/Ablehnung erfolgt ausschließlich im internen Channel per Button. +- Lagerdaten werden nie an den Einreicher oder in öffentliche Posts ausgegeben. +- Alle relevanten Änderungen werden in `order_events` auditiert. + +Discord Buttons sind Message Components mit `custom_id`; beim Klick erhält der Bot eine Component Interaction und ordnet sie damit wieder dem Auftrag zu. + +## Setup + +1. Discord Developer Portal: Bot erstellen, Token kopieren, Bot in deinen Server einladen. +2. Bot-Berechtigungen: + - `Send Messages` + - `Use Slash Commands` + - `Read Message History` + - `Embed Links` + - `Create Public Threads` oder `Create Private Threads` je nach Channel-Einstellung + - `Send Messages in Threads` +3. `.env.example` nach `.env` kopieren oder Umgebungsvariablen setzen. +4. Starten: + +```bash +go mod tidy +go run . +``` + +Für schnelle Command-Updates in Entwicklung `DISCORD_GUILD_ID` setzen. Ohne Guild-ID werden Slash Commands global registriert, was länger dauern kann. + +## Wichtige Umgebungsvariablen + +```env +DISCORD_TOKEN=... +DISCORD_APP_ID=... +DISCORD_GUILD_ID=... +DISCORD_PUBLIC_ORDER_CHANNEL_ID=... +DISCORD_INTERNAL_ORDER_CHANNEL_ID=... +DISCORD_AUDIT_CHANNEL_ID=... +DISCORD_ADMIN_ROLE_IDS=roleId1,roleId2 +DISCORD_TEAM_ROLE_IDS=roleId3,roleId4 +DATABASE_PATH=orders.db +``` + +`DISCORD_INTERNAL_ORDER_CHANNEL_ID` sollte ein privater Team-Channel sein. Nur dort erscheinen Lagerprüfung und Annahme-/Ablehnen-Buttons. + +## User Commands + +### `/auftrag` + +Erstellt einen Auftrag mit: + +- `ware` +- `qualitaet` 0-1000 +- `menge` als Zahl +- `einheit` `SCU` oder `Stück` +- `frist` im Format `YYYY-MM-DD` +- `lieferort` +- `budget` in aUEC + +### `/auftrag_status id:` + +Zeigt den Auftrag. Team/Admin sieht die interne Ansicht inklusive Lagerprüfung, normale Nutzer nur eigene Aufträge ohne Lagerdaten. + +### `/auftrag_liste status: limit:` + +Listet Aufträge. Team/Admin sieht zusätzlich interne Zuordnungen. + +### `/auftrag_abschliessen id:` + +Einreicher, Annehmer oder Team/Admin können abschließen. + +### `/auftrag_abbrechen id: grund:` + +Einreicher oder Team/Admin können abbrechen. + +### `/auftrag_nachricht id: text:` + +Leitet eine Nachricht per DM an Einreicher und Annehmer weiter und schreibt sie zusätzlich in den internen Auftragsthread. + +## Team/Admin Commands + +### `/auftrag_status_setzen` + +Team/Admin kann den Status manuell setzen: + +- `open` +- `accepted` +- `in_delivery` +- `delivered` +- `completed` +- `declined` +- `cancelled` + +## Lager Commands + +Nur Admins können Lagerbestände verändern. Team/Admin kann Lager prüfen und anzeigen. + +### `/lager_add` + +Fügt Bestand hinzu oder setzt ihn absolut. + +```text +/lager_add ware:Gold qualitaet:900 menge:64 einheit:SCU ort:Orison modus:addieren +/lager_add ware:Gold qualitaet:900 menge:100 einheit:SCU ort:Orison modus:setzen +``` + +### `/lager_remove` + +Reduziert Bestand. Der Bot verhindert negative Bestände. + +```text +/lager_remove ware:Gold qualitaet:900 menge:10 einheit:SCU ort:Orison +``` + +### `/lager_liste` + +Zeigt interne Lagerbestände ephemeral an. + +```text +/lager_liste suche:Gold limit:20 +``` + +### `/lager_check` + +Prüft intern, ob eine Menge verfügbar ist. + +```text +/lager_check ware:Gold qualitaet:900 menge:64 einheit:SCU +``` + +## Statusmodell + +```text +open -> accepted -> in_delivery -> delivered -> completed + \-> cancelled +open -> declined +``` + +## Datenbanktabellen + +- `orders`: Aufträge und Discord-Message-Referenzen +- `inventory`: interne Lagerpositionen +- `order_events`: Audit-Log für Aufträge und Lageränderungen + +SQLite wird mit WAL-Modus und `busy_timeout` betrieben, damit kleine produktive Deployments robuster laufen. + +## Produktionshinweise + +- Setze `DISCORD_INTERNAL_ORDER_CHANNEL_ID` auf einen strikt privaten Channel. +- Gib Lager-Commands nur einer kleinen Admin-Rolle. +- Sichere `orders.db` regelmäßig. +- Führe den Bot als systemd-Service oder Container aus. +- Aktiviere Monitoring für Logs und Neustarts. +- Teste DMs: Manche Nutzer blockieren DMs von Servermitgliedern; der Bot loggt solche Fehler, kann sie aber nicht erzwingen. diff --git a/config.go b/config.go new file mode 100644 index 0000000..bf05d36 --- /dev/null +++ b/config.go @@ -0,0 +1,58 @@ +package main + +import ( + "log" + "os" + "strings" +) + +type Config struct { + Token string + AppID string + GuildID string + PublicOrderChannelID string + InternalOrderChannelID string + AuditChannelID string + AdminRoleIDs []string + TeamRoleIDs []string + DatabasePath string +} + +func loadConfig() Config { + cfg := Config{ + Token: os.Getenv("DISCORD_TOKEN"), + AppID: os.Getenv("DISCORD_APP_ID"), + GuildID: os.Getenv("DISCORD_GUILD_ID"), + PublicOrderChannelID: getenv("DISCORD_PUBLIC_ORDER_CHANNEL_ID", os.Getenv("DISCORD_ORDER_CHANNEL_ID")), + InternalOrderChannelID: getenv("DISCORD_INTERNAL_ORDER_CHANNEL_ID", getenv("DISCORD_ORDER_CHANNEL_ID", "")), + AuditChannelID: os.Getenv("DISCORD_AUDIT_CHANNEL_ID"), + AdminRoleIDs: splitCSV(os.Getenv("DISCORD_ADMIN_ROLE_IDS")), + TeamRoleIDs: splitCSV(os.Getenv("DISCORD_TEAM_ROLE_IDS")), + DatabasePath: getenv("DATABASE_PATH", "orders.db"), + } + if cfg.Token == "" || cfg.AppID == "" || cfg.InternalOrderChannelID == "" { + log.Fatal("Bitte DISCORD_TOKEN, DISCORD_APP_ID und DISCORD_INTERNAL_ORDER_CHANNEL_ID setzen") + } + if len(cfg.AdminRoleIDs) == 0 { + log.Println("WARNUNG: DISCORD_ADMIN_ROLE_IDS ist leer. Admin-Kommandos sind nur fuer Server-Administratoren verfuegbar.") + } + return cfg +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func splitCSV(v string) []string { + var out []string + for _, p := range strings.Split(v, ",") { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} diff --git a/db.go b/db.go new file mode 100644 index 0000000..804a058 --- /dev/null +++ b/db.go @@ -0,0 +1,400 @@ +package main + +import ( + "database/sql" + "errors" + "fmt" + "math" + "strings" + "time" + + _ "github.com/glebarez/sqlite" +) + +type Store struct{ db *sql.DB } + +func NewStore(path string) (*Store, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + if _, err := db.Exec(`PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL;`); err != nil { + return nil, err + } + s := &Store{db: db} + return s, s.migrate() +} + +func (s *Store) Close() error { return s.db.Close() } + +func (s *Store) migrate() error { + _, err := s.db.Exec(` +CREATE TABLE IF NOT EXISTS orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + submitter_id TEXT NOT NULL, + submitter_name TEXT NOT NULL, + acceptor_id TEXT DEFAULT '', + acceptor_name TEXT DEFAULT '', + commodity TEXT NOT NULL, + quality INTEGER NOT NULL CHECK(quality >= 0 AND quality <= 1000), + quantity TEXT NOT NULL DEFAULT '', + quantity_amount REAL NOT NULL DEFAULT 0, + quantity_unit TEXT NOT NULL DEFAULT '', + deadline TEXT NOT NULL, + delivery_place TEXT NOT NULL, + max_budget_auec INTEGER NOT NULL, + status TEXT NOT NULL, + channel_id TEXT DEFAULT '', + message_id TEXT DEFAULT '', + public_channel_id TEXT DEFAULT '', + public_message_id TEXT DEFAULT '', + internal_channel_id TEXT DEFAULT '', + internal_message_id TEXT DEFAULT '', + thread_id TEXT DEFAULT '', + last_feedback TEXT DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); +CREATE INDEX IF NOT EXISTS idx_orders_submitter ON orders(submitter_id); +CREATE INDEX IF NOT EXISTS idx_orders_acceptor ON orders(acceptor_id); + +CREATE TABLE IF NOT EXISTS inventory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + normalized_name TEXT NOT NULL, + quality INTEGER NOT NULL CHECK(quality >= 0 AND quality <= 1000), + quantity_amount REAL NOT NULL CHECK(quantity_amount >= 0), + quantity_unit TEXT NOT NULL, + location TEXT DEFAULT '', + note TEXT DEFAULT '', + updated_by_id TEXT NOT NULL, + updated_by_name TEXT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE(normalized_name, quality, quantity_unit, location) +); +CREATE INDEX IF NOT EXISTS idx_inventory_lookup ON inventory(normalized_name, quality, quantity_unit); + +CREATE TABLE IF NOT EXISTS order_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER, + actor_id TEXT NOT NULL, + actor_name TEXT NOT NULL, + event_type TEXT NOT NULL, + message TEXT NOT NULL, + created_at DATETIME NOT NULL, + FOREIGN KEY(order_id) REFERENCES orders(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_order_events_order ON order_events(order_id); +`) + if err != nil { + return err + } + return s.ensureColumns() +} + +func (s *Store) ensureColumns() error { + columns := map[string]string{ + "quantity_amount": "ALTER TABLE orders ADD COLUMN quantity_amount REAL NOT NULL DEFAULT 0", + "quantity_unit": "ALTER TABLE orders ADD COLUMN quantity_unit TEXT NOT NULL DEFAULT ''", + "public_channel_id": "ALTER TABLE orders ADD COLUMN public_channel_id TEXT DEFAULT ''", + "public_message_id": "ALTER TABLE orders ADD COLUMN public_message_id TEXT DEFAULT ''", + "internal_channel_id": "ALTER TABLE orders ADD COLUMN internal_channel_id TEXT DEFAULT ''", + "internal_message_id": "ALTER TABLE orders ADD COLUMN internal_message_id TEXT DEFAULT ''", + "thread_id": "ALTER TABLE orders ADD COLUMN thread_id TEXT DEFAULT ''", + } + for col, stmt := range columns { + if !s.columnExists("orders", col) { + if _, err := s.db.Exec(stmt); err != nil { + return err + } + } + } + return nil +} + +func (s *Store) columnExists(table, column string) bool { + rows, err := s.db.Query(`PRAGMA table_info(` + table + `)`) + if err != nil { + return false + } + defer rows.Close() + for rows.Next() { + var cid int + var name, typ string + var notnull int + var dflt any + var pk int + if rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk) == nil && name == column { + return true + } + } + return false +} + +func normalizeName(v string) string { return strings.ToLower(strings.TrimSpace(v)) } + +func (s *Store) CreateOrder(o *Order, actorID, actorName string) (int64, error) { + now := time.Now().UTC() + tx, err := s.db.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + res, err := tx.Exec(`INSERT INTO orders +(submitter_id, submitter_name, commodity, quality, quantity, quantity_amount, quantity_unit, deadline, delivery_place, max_budget_auec, status, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + o.SubmitterID, o.SubmitterName, o.Commodity, o.Quality, quantityString(o.QuantityAmount, o.QuantityUnit), o.QuantityAmount, o.QuantityUnit, o.Deadline, o.DeliveryPlace, o.MaxBudgetAUEC, StatusOpen, now, now) + if err != nil { + return 0, err + } + id, err := res.LastInsertId() + if err != nil { + return 0, err + } + if err := addEventTx(tx, id, actorID, actorName, EventOrderCreated, "Auftrag erstellt"); err != nil { + return 0, err + } + return id, tx.Commit() +} + +func (s *Store) SetMessages(id int64, publicChannelID, publicMessageID, internalChannelID, internalMessageID, threadID string) error { + _, err := s.db.Exec(`UPDATE orders SET public_channel_id=?, public_message_id=?, internal_channel_id=?, internal_message_id=?, thread_id=?, channel_id=?, message_id=?, updated_at=? WHERE id=?`, publicChannelID, publicMessageID, internalChannelID, internalMessageID, threadID, internalChannelID, internalMessageID, time.Now().UTC(), id) + return err +} + +func (s *Store) GetOrder(id int64) (*Order, error) { + row := s.db.QueryRow(`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 id=?`, id) + return scanOrder(row) +} + +func scanOrder(scanner interface{ Scan(dest ...any) error }) (*Order, error) { + o := &Order{} + if err := scanner.Scan(&o.ID, &o.SubmitterID, &o.SubmitterName, &o.AcceptorID, &o.AcceptorName, &o.Commodity, &o.Quality, &o.QuantityAmount, &o.QuantityUnit, &o.Deadline, &o.DeliveryPlace, &o.MaxBudgetAUEC, &o.Status, &o.PublicChannelID, &o.PublicMessageID, &o.InternalChannelID, &o.InternalMessageID, &o.ThreadID, &o.CreatedAt, &o.UpdatedAt, &o.LastFeedback); err != nil { + return nil, err + } + return o, nil +} + +func (s *Store) ListOrders(status string, limit int) ([]Order, error) { + if limit <= 0 || limit > 50 { + limit = 10 + } + 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` + args := []any{} + if status != "" { + query += ` WHERE status=?` + args = append(args, status) + } + query += ` ORDER BY created_at DESC LIMIT ?` + args = append(args, limit) + rows, err := s.db.Query(query, args...) + 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) AcceptOrder(id int64, userID, userName string) (*Order, error) { + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + res, err := tx.Exec(`UPDATE orders SET status=?, acceptor_id=?, acceptor_name=?, updated_at=? WHERE id=? AND status=?`, StatusAccepted, userID, userName, time.Now().UTC(), id, StatusOpen) + if err != nil { + return nil, err + } + n, _ := res.RowsAffected() + if n == 0 { + return nil, errors.New("auftrag ist nicht mehr offen") + } + if err := addEventTx(tx, id, userID, userName, EventOrderAccepted, "Auftrag angenommen"); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return s.GetOrder(id) +} + +func (s *Store) DeclineOrder(id int64, userID, userName, feedback string) (*Order, error) { + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + res, err := tx.Exec(`UPDATE orders SET status=?, last_feedback=?, updated_at=? WHERE id=? AND status=?`, StatusDeclined, feedback, time.Now().UTC(), id, StatusOpen) + if err != nil { + return nil, err + } + n, _ := res.RowsAffected() + if n == 0 { + return nil, errors.New("auftrag ist nicht mehr offen") + } + if err := addEventTx(tx, id, userID, userName, EventOrderDeclined, feedback); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return s.GetOrder(id) +} + +func (s *Store) SetStatus(id int64, status, feedback, actorID, actorName string) (*Order, error) { + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + res, err := tx.Exec(`UPDATE orders SET status=?, last_feedback=?, updated_at=? WHERE id=?`, status, feedback, time.Now().UTC(), id) + if err != nil { + return nil, err + } + n, _ := res.RowsAffected() + 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 { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return s.GetOrder(id) +} + +func (s *Store) AddEvent(orderID int64, actorID, actorName, eventType, message string) error { + _, err := s.db.Exec(`INSERT INTO order_events(order_id, actor_id, actor_name, event_type, message, created_at) VALUES (?, ?, ?, ?, ?, ?)`, nullableOrderID(orderID), actorID, actorName, eventType, message, time.Now().UTC()) + return err +} + +func addEventTx(tx *sql.Tx, orderID int64, actorID, actorName, eventType, message string) error { + _, err := tx.Exec(`INSERT INTO order_events(order_id, actor_id, actor_name, event_type, message, created_at) VALUES (?, ?, ?, ?, ?, ?)`, nullableOrderID(orderID), actorID, actorName, eventType, message, time.Now().UTC()) + return err +} + +func nullableOrderID(id int64) any { + if id <= 0 { + return nil + } + return id +} + +func (s *Store) UpsertInventory(item InventoryItem, delta bool) (*InventoryItem, error) { + now := time.Now().UTC() + item.NormalizedName = normalizeName(item.Name) + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + var existing InventoryItem + err = tx.QueryRow(`SELECT id, name, normalized_name, quality, quantity_amount, quantity_unit, location, note, updated_by_id, updated_by_name, created_at, updated_at FROM inventory WHERE normalized_name=? AND quality=? AND quantity_unit=? AND location=?`, item.NormalizedName, item.Quality, item.QuantityUnit, item.Location).Scan(&existing.ID, &existing.Name, &existing.NormalizedName, &existing.Quality, &existing.QuantityAmount, &existing.QuantityUnit, &existing.Location, &existing.Note, &existing.UpdatedByID, &existing.UpdatedByName, &existing.CreatedAt, &existing.UpdatedAt) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + if errors.Is(err, sql.ErrNoRows) { + _, err = tx.Exec(`INSERT INTO inventory(name, normalized_name, quality, quantity_amount, quantity_unit, location, note, updated_by_id, updated_by_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, item.Name, item.NormalizedName, item.Quality, item.QuantityAmount, item.QuantityUnit, item.Location, item.Note, item.UpdatedByID, item.UpdatedByName, now, now) + } else { + newQty := item.QuantityAmount + if delta { + newQty = existing.QuantityAmount + item.QuantityAmount + } + if newQty < 0 { + return nil, errors.New("lagerbestand darf nicht negativ werden") + } + _, err = tx.Exec(`UPDATE inventory SET name=?, quantity_amount=?, note=?, updated_by_id=?, updated_by_name=?, updated_at=? WHERE id=?`, item.Name, newQty, item.Note, item.UpdatedByID, item.UpdatedByName, now, existing.ID) + } + if err != nil { + return nil, err + } + if err := addEventTx(tx, 0, item.UpdatedByID, item.UpdatedByName, EventInventoryChanged, fmt.Sprintf("Lager %s Q%d %s %s", item.Name, item.Quality, formatAmount(item.QuantityAmount), item.QuantityUnit)); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return s.GetInventoryByKey(item.NormalizedName, item.Quality, item.QuantityUnit, item.Location) +} + +func (s *Store) GetInventoryByKey(normalized string, quality int, unit, location string) (*InventoryItem, error) { + row := s.db.QueryRow(`SELECT id, name, normalized_name, quality, quantity_amount, quantity_unit, location, note, updated_by_id, updated_by_name, created_at, updated_at FROM inventory WHERE normalized_name=? AND quality=? AND quantity_unit=? AND location=?`, normalized, quality, unit, location) + return scanInventory(row) +} + +func scanInventory(scanner interface{ Scan(dest ...any) error }) (*InventoryItem, error) { + it := &InventoryItem{} + if err := scanner.Scan(&it.ID, &it.Name, &it.NormalizedName, &it.Quality, &it.QuantityAmount, &it.QuantityUnit, &it.Location, &it.Note, &it.UpdatedByID, &it.UpdatedByName, &it.CreatedAt, &it.UpdatedAt); err != nil { + return nil, err + } + return it, nil +} + +func (s *Store) ListInventory(query string, limit int) ([]InventoryItem, error) { + if limit <= 0 || limit > 50 { + limit = 20 + } + q := `SELECT id, name, normalized_name, quality, quantity_amount, quantity_unit, location, note, updated_by_id, updated_by_name, created_at, updated_at FROM inventory` + args := []any{} + if strings.TrimSpace(query) != "" { + q += ` WHERE normalized_name LIKE ? OR location LIKE ?` + like := "%" + normalizeName(query) + "%" + args = append(args, like, like) + } + q += ` ORDER BY normalized_name, quality DESC LIMIT ?` + args = append(args, limit) + rows, err := s.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []InventoryItem + for rows.Next() { + it, err := scanInventory(rows) + if err != nil { + return nil, err + } + out = append(out, *it) + } + return out, rows.Err() +} + +func (s *Store) FindInventoryForOrder(o *Order) ([]InventoryMatch, float64, error) { + rows, err := s.db.Query(`SELECT id, name, normalized_name, quality, quantity_amount, quantity_unit, location, note, updated_by_id, updated_by_name, created_at, updated_at FROM inventory WHERE normalized_name=? AND quality>=? AND quantity_unit=? ORDER BY quality DESC, quantity_amount DESC`, normalizeName(o.Commodity), o.Quality, o.QuantityUnit) + if err != nil { + return nil, 0, err + } + defer rows.Close() + var matches []InventoryMatch + var total float64 + for rows.Next() { + it, err := scanInventory(rows) + if err != nil { + return nil, 0, err + } + total += it.QuantityAmount + matches = append(matches, InventoryMatch{Item: *it, RequestedQuantity: o.QuantityAmount, Available: it.QuantityAmount >= o.QuantityAmount}) + } + return matches, total, rows.Err() +} + +func quantityString(amount float64, unit string) string { + return fmt.Sprintf("%s %s", formatAmount(amount), unit) +} +func formatAmount(v float64) string { + if math.Abs(v-math.Round(v)) < 0.00001 { + return fmt.Sprintf("%.0f", v) + } + return fmt.Sprintf("%.2f", v) +} diff --git a/discord.go b/discord.go new file mode 100644 index 0000000..13c31ef --- /dev/null +++ b/discord.go @@ -0,0 +1,615 @@ +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 +} + +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.IntentsGuildMessages | discordgo.IntentsGuildMembers | discordgo.IntentsDirectMessages + b := &Bot{cfg: cfg, store: store, dg: dg} + 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 + } + return b.registerCommands() +} + +func (b *Bot) Stop() { _ = 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 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: "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 Aufträge", Options: []*discordgo.ApplicationCommandOption{{Name: "status", Description: "Optionaler Status", Type: discordgo.ApplicationCommandOptionString, Required: false, Choices: statusChoices()}, {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 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_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"}}}}}, + } + 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 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}} +} + +func (b *Bot) onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { + switch i.Type { + case discordgo.InteractionApplicationCommand: + b.handleCommand(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_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) + } +} + +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) { + status := optString(i, "status") + limit := int(optIntDefault(i, "limit", 10)) + orders, err := b.store.ListOrders(status, limit) + if err != nil { + b.replyEphemeral(s, i, err.Error()) + return + } + if len(orders) == 0 { + b.replyEphemeral(s, i, "Keine 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, 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, 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)) +} + +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 + } + 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", userString(i), updated.Name, updated.Quality, formatAmount(updated.QuantityAmount), updated.QuantityUnit, updated.Location)) + 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 + } + var lines []string + for _, it := range items { + lines = append(lines, fmt.Sprintf("#%d — %s Q%d: %s %s%s%s", it.ID, it.Name, it.Quality, formatAmount(it.QuantityAmount), it.QuantityUnit, locationSuffix(it.Location), noteSuffix(it.Note))) + } + b.replyEphemeral(s, i, strings.Join(lines, "\n")) +} + +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) 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: 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 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) 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) { + ch, err := b.dg.UserChannelCreate(userID) + if err != nil { + log.Println("dm channel failed:", err) + return + } + if _, err := b.dg.ChannelMessageSend(ch.ID, body); err != nil { + log.Println("dm send failed:", 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 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 +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..66702b7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +services: + starauftrag-bot: + build: . + restart: unless-stopped + env_file: .env + volumes: + - ./data:/data diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bb00886 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module starauftrag-bot + +go 1.22 + +require ( + github.com/bwmarrin/discordgo v0.28.1 + github.com/glebarez/sqlite v1.11.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect + golang.org/x/sys v0.7.0 // indirect + gorm.io/gorm v1.25.7 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..96ae8e5 --- /dev/null +++ b/go.sum @@ -0,0 +1,43 @@ +github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4= +github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/main.go b/main.go new file mode 100644 index 0000000..3cd47e4 --- /dev/null +++ b/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" +) + +func main() { + cfg := loadConfig() + store, err := NewStore(cfg.DatabasePath) + if err != nil { + log.Fatal(err) + } + defer store.Close() + bot, err := NewBot(cfg, store) + if err != nil { + log.Fatal(err) + } + if err := bot.Start(); err != nil { + log.Fatal(err) + } + log.Println("Bot läuft. Beenden mit Ctrl+C.") + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + bot.Stop() +} diff --git a/models.go b/models.go new file mode 100644 index 0000000..57f8be8 --- /dev/null +++ b/models.go @@ -0,0 +1,67 @@ +package main + +import "time" + +const ( + StatusOpen = "open" + StatusAccepted = "accepted" + StatusInDelivery = "in_delivery" + StatusDelivered = "delivered" + StatusCompleted = "completed" + StatusDeclined = "declined" + StatusCancelled = "cancelled" +) + +const ( + EventOrderCreated = "order_created" + EventOrderAccepted = "order_accepted" + EventOrderDeclined = "order_declined" + EventOrderStatus = "order_status_changed" + EventMessageForwarded = "message_forwarded" + EventInventoryChanged = "inventory_changed" +) + +type Order struct { + ID int64 + SubmitterID string + SubmitterName string + AcceptorID string + AcceptorName string + Commodity string + Quality int + QuantityAmount float64 + QuantityUnit string + Deadline string + DeliveryPlace string + MaxBudgetAUEC int64 + Status string + PublicChannelID string + PublicMessageID string + InternalChannelID string + InternalMessageID string + ThreadID string + LastFeedback string + CreatedAt time.Time + UpdatedAt time.Time +} + +type InventoryItem struct { + ID int64 + Name string + NormalizedName string + Quality int + QuantityAmount float64 + QuantityUnit string + Location string + Note string + UpdatedByID string + UpdatedByName string + CreatedAt time.Time + UpdatedAt time.Time +} + +type InventoryMatch struct { + Item InventoryItem + RequestedQuantity float64 + Available bool +}