init
Some checks failed
release-tag / release-image (push) Failing after 56s

This commit is contained in:
2026-05-29 09:16:05 +02:00
parent dfbda28cf3
commit 8fd368860c
12 changed files with 1494 additions and 1 deletions

24
.env.example Normal file
View File

@@ -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

View File

@@ -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 }}

15
Dockerfile Normal file
View File

@@ -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"]

160
README.md
View File

@@ -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:<id>`
Zeigt den Auftrag. Team/Admin sieht die interne Ansicht inklusive Lagerprüfung, normale Nutzer nur eigene Aufträge ohne Lagerdaten.
### `/auftrag_liste status:<optional> limit:<optional>`
Listet Aufträge. Team/Admin sieht zusätzlich interne Zuordnungen.
### `/auftrag_abschliessen id:<id>`
Einreicher, Annehmer oder Team/Admin können abschließen.
### `/auftrag_abbrechen id:<id> grund:<optional>`
Einreicher oder Team/Admin können abbrechen.
### `/auftrag_nachricht id:<id> text:<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.

58
config.go Normal file
View File

@@ -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
}

400
db.go Normal file
View File

@@ -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, &notnull, &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)
}

615
discord.go Normal file
View File

@@ -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
}

7
docker-compose.yml Normal file
View File

@@ -0,0 +1,7 @@
services:
starauftrag-bot:
build: .
restart: unless-stopped
env_file: .env
volumes:
- ./data:/data

26
go.mod Normal file
View File

@@ -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
)

43
go.sum Normal file
View File

@@ -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=

29
main.go Normal file
View File

@@ -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()
}

67
models.go Normal file
View File

@@ -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
}