This commit is contained in:
24
.env.example
Normal file
24
.env.example
Normal 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
|
||||
51
.gitea/workflows/registry.yml
Normal file
51
.gitea/workflows/registry.yml
Normal 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
15
Dockerfile
Normal 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
160
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:<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
58
config.go
Normal 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
400
db.go
Normal 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, ¬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)
|
||||
}
|
||||
615
discord.go
Normal file
615
discord.go
Normal 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
7
docker-compose.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
starauftrag-bot:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./data:/data
|
||||
26
go.mod
Normal file
26
go.mod
Normal 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
43
go.sum
Normal 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
29
main.go
Normal 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
67
models.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user