Update mit Trading-Modul
All checks were successful
release-tag / release-image (push) Successful in 2m6s
All checks were successful
release-tag / release-image (push) Successful in 2m6s
This commit is contained in:
31
.env.example
31
.env.example
@@ -7,20 +7,39 @@ DISCORD_APP_ID=ApplicationIDHere
|
||||
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 # 1509819718922670271
|
||||
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 # 1509819679487688817
|
||||
DISCORD_INTERNAL_ORDER_CHANNEL_ID=PrivateTeamOrderChannelIDRequired
|
||||
|
||||
# Optional audit/log channel for admin traceability.
|
||||
DISCORD_AUDIT_CHANNEL_ID=AuditLogChannelIDOptional # 1509819633551802409
|
||||
DISCORD_AUDIT_CHANNEL_ID=AuditLogChannelIDOptional
|
||||
|
||||
# Comma-separated role IDs. Admin role can modify inventory and force status changes.
|
||||
DISCORD_ADMIN_ROLE_IDS=123456789012345678,234567890123456789 # 1509819305188003931
|
||||
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 # 1509819434330755233
|
||||
DISCORD_TEAM_ROLE_IDS=345678901234567890
|
||||
|
||||
DATABASE_PATH=/data/orders.db
|
||||
DATABASE_PATH=orders.db
|
||||
|
||||
# After this many hours, internal threads for archived orders are deleted.
|
||||
# Applies to: abgeschlossen, abgelehnt, abgebrochen. Set 0 to disable cleanup.
|
||||
THREAD_DELETE_AFTER_HOURS=72
|
||||
|
||||
# Trading module
|
||||
# Members with one of these roles may create Trading-Runs. Team/Admin can also use the module.
|
||||
DISCORD_TRADING_ROLE_IDS=456789012345678901
|
||||
|
||||
# Channel where /trading_report posts monthly results. Falls back to audit/internal channel when empty.
|
||||
DISCORD_TRADING_REPORT_CHANNEL_ID=TradingReportChannelIDOptional
|
||||
|
||||
# Comma-separated autocomplete lists.
|
||||
TRADING_SHIPS=Caterpillar,C2 Hercules,Freelancer MAX,Hull A,Hull C,Taurus
|
||||
TRADING_COMMODITIES=Gold,Beryl,Diamond,Laranite,Agricium,Quantanium,Processed Food,Medical Supplies
|
||||
|
||||
# Organization share per purpose. Supports either 10 or 0.10 for 10%.
|
||||
TRADING_SHARE_TRADE=10
|
||||
TRADING_SHARE_HAULING=5
|
||||
TRADING_SHARE_EVENT=0
|
||||
TRADING_SHARE_INTERN=0
|
||||
|
||||
26
.env.example_
Normal file
26
.env.example_
Normal file
@@ -0,0 +1,26 @@
|
||||
# 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 # 1509819718922670271
|
||||
|
||||
# Private/internal channel for team operations. The bot posts buttons and warehouse checks here.
|
||||
DISCORD_INTERNAL_ORDER_CHANNEL_ID=PrivateTeamOrderChannelIDRequired # 1509819679487688817
|
||||
|
||||
# Optional audit/log channel for admin traceability.
|
||||
DISCORD_AUDIT_CHANNEL_ID=AuditLogChannelIDOptional # 1509819633551802409
|
||||
|
||||
# Comma-separated role IDs. Admin role can modify inventory and force status changes.
|
||||
DISCORD_ADMIN_ROLE_IDS=123456789012345678,234567890123456789 # 1509819305188003931
|
||||
|
||||
# Comma-separated role IDs. Team role can see internal orders, accept/decline, check warehouse.
|
||||
DISCORD_TEAM_ROLE_IDS=345678901234567890 # 1509819434330755233
|
||||
|
||||
DATABASE_PATH=/data/orders.db
|
||||
|
||||
THREAD_DELETE_AFTER_HOURS=72
|
||||
43
config.go
43
config.go
@@ -17,6 +17,11 @@ type Config struct {
|
||||
AuditChannelID string
|
||||
AdminRoleIDs []string
|
||||
TeamRoleIDs []string
|
||||
TradingRoleIDs []string
|
||||
TradingReportChannelID string
|
||||
TradingShips []string
|
||||
TradingCommodities []string
|
||||
TradingOrgSharePercent map[string]float64
|
||||
DatabasePath string
|
||||
ThreadDeleteAfter time.Duration
|
||||
}
|
||||
@@ -31,6 +36,11 @@ func loadConfig() Config {
|
||||
AuditChannelID: os.Getenv("DISCORD_AUDIT_CHANNEL_ID"),
|
||||
AdminRoleIDs: splitCSV(os.Getenv("DISCORD_ADMIN_ROLE_IDS")),
|
||||
TeamRoleIDs: splitCSV(os.Getenv("DISCORD_TEAM_ROLE_IDS")),
|
||||
TradingRoleIDs: splitCSV(os.Getenv("DISCORD_TRADING_ROLE_IDS")),
|
||||
TradingReportChannelID: getenv("DISCORD_TRADING_REPORT_CHANNEL_ID", os.Getenv("DISCORD_AUDIT_CHANNEL_ID")),
|
||||
TradingShips: splitCSV(os.Getenv("TRADING_SHIPS")),
|
||||
TradingCommodities: splitCSV(os.Getenv("TRADING_COMMODITIES")),
|
||||
TradingOrgSharePercent: tradingShareConfig(),
|
||||
DatabasePath: getenv("DATABASE_PATH", "orders.db"),
|
||||
ThreadDeleteAfter: durationHoursEnv("THREAD_DELETE_AFTER_HOURS", 72),
|
||||
}
|
||||
@@ -73,3 +83,36 @@ func durationHoursEnv(key string, fallbackHours int) time.Duration {
|
||||
}
|
||||
return time.Duration(hours) * time.Hour
|
||||
}
|
||||
|
||||
func floatEnv(key string, fallback float64) float64 {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(strings.ReplaceAll(v, ",", "."), 64)
|
||||
if err != nil {
|
||||
log.Printf("WARNUNG: %s ist ungültig, verwende %.2f", key, fallback)
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func tradingShareConfig() map[string]float64 {
|
||||
return map[string]float64{
|
||||
TradingPurposeTrade: normalizePercent(floatEnv("TRADING_SHARE_TRADE", 0)),
|
||||
TradingPurposeHauling: normalizePercent(floatEnv("TRADING_SHARE_HAULING", 0)),
|
||||
TradingPurposeEvent: normalizePercent(floatEnv("TRADING_SHARE_EVENT", 0)),
|
||||
TradingPurposeIntern: normalizePercent(floatEnv("TRADING_SHARE_INTERN", 0)),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePercent(v float64) float64 {
|
||||
// Erlaubt sowohl "10" als auch "0.10" in der .env.
|
||||
if v > 1 {
|
||||
return v / 100
|
||||
}
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
205
db.go
205
db.go
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -102,6 +103,9 @@ CREATE INDEX IF NOT EXISTS idx_order_events_order ON order_events(order_id);
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.createTradingSchema(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.Exec(`INSERT OR IGNORE INTO storage_locations(name, normalized_name, created_by_id, created_by_name, created_at, updated_at)
|
||||
SELECT DISTINCT TRIM(location), lower(TRIM(location)), 'migration', 'migration', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM inventory
|
||||
@@ -608,3 +612,204 @@ func formatAmount(v float64) string {
|
||||
}
|
||||
return fmt.Sprintf("%.2f", v)
|
||||
}
|
||||
|
||||
func (s *Store) createTradingSchema() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS trading_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
user_name TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
ship TEXT NOT NULL,
|
||||
buy_value INTEGER NOT NULL DEFAULT 0,
|
||||
sell_value INTEGER NOT NULL DEFAULT 0,
|
||||
costs INTEGER NOT NULL DEFAULT 0,
|
||||
repetitions INTEGER NOT NULL DEFAULT 1,
|
||||
scu REAL NOT NULL DEFAULT 0,
|
||||
distance REAL NOT NULL DEFAULT 0,
|
||||
scu_grade TEXT NOT NULL DEFAULT '',
|
||||
commodity TEXT NOT NULL,
|
||||
post_a TEXT NOT NULL DEFAULT '',
|
||||
post_b TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
month INTEGER NOT NULL CHECK(month >= 1 AND month <= 12),
|
||||
year INTEGER NOT NULL CHECK(year >= 2000 AND year <= 2200),
|
||||
org_percent REAL NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
deleted_at DATETIME,
|
||||
deleted_by_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_trading_month_year ON trading_runs(year, month);
|
||||
CREATE INDEX IF NOT EXISTS idx_trading_user ON trading_runs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_trading_deleted ON trading_runs(deleted_at);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) CreateTradingRun(t *TradingRun) (int64, error) {
|
||||
now := time.Now().UTC()
|
||||
if t.Month < 1 || t.Month > 12 {
|
||||
return 0, errors.New("monat muss zwischen 1 und 12 liegen")
|
||||
}
|
||||
if t.Year < 2000 || t.Year > 2200 {
|
||||
return 0, errors.New("jahr muss zwischen 2000 und 2200 liegen")
|
||||
}
|
||||
if t.Repetitions <= 0 {
|
||||
t.Repetitions = 1
|
||||
}
|
||||
res, err := s.db.Exec(`INSERT INTO trading_runs
|
||||
(user_id, user_name, purpose, ship, buy_value, sell_value, costs, repetitions, scu, distance, scu_grade, commodity, post_a, post_b, note, month, year, org_percent, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
t.UserID, t.UserName, t.Purpose, t.Ship, t.BuyValue, t.SellValue, t.Costs, t.Repetitions, t.SCU, t.Distance, t.SCUGrade, t.Commodity, t.PostA, t.PostB, t.Note, t.Month, t.Year, t.OrgPercent, now, now)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func scanTradingRun(scanner interface{ Scan(dest ...any) error }) (*TradingRun, error) {
|
||||
t := &TradingRun{}
|
||||
if err := scanner.Scan(&t.ID, &t.UserID, &t.UserName, &t.Purpose, &t.Ship, &t.BuyValue, &t.SellValue, &t.Costs, &t.Repetitions, &t.SCU, &t.Distance, &t.SCUGrade, &t.Commodity, &t.PostA, &t.PostB, &t.Note, &t.Month, &t.Year, &t.OrgPercent, &t.CreatedAt, &t.UpdatedAt, &t.DeletedAt, &t.DeletedByID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetTradingRun(id int64) (*TradingRun, error) {
|
||||
row := s.db.QueryRow(`SELECT id, user_id, user_name, purpose, ship, buy_value, sell_value, costs, repetitions, scu, distance, scu_grade, commodity, post_a, post_b, note, month, year, org_percent, created_at, updated_at, deleted_at, deleted_by_id FROM trading_runs WHERE id=?`, id)
|
||||
return scanTradingRun(row)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteTradingRun(id int64, actorID string) (*TradingRun, error) {
|
||||
t, err := s.GetTradingRun(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.DeletedAt.Valid {
|
||||
return nil, errors.New("trading-run ist bereits gelöscht")
|
||||
}
|
||||
_, err = s.db.Exec(`UPDATE trading_runs SET deleted_at=?, deleted_by_id=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().UTC(), actorID, time.Now().UTC(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetTradingRun(id)
|
||||
}
|
||||
|
||||
func (s *Store) ListTradingRuns(f TradingListFilter) ([]TradingRun, error) {
|
||||
if f.Limit <= 0 {
|
||||
f.Limit = 20
|
||||
}
|
||||
if f.Limit > 5000 {
|
||||
f.Limit = 5000
|
||||
}
|
||||
q := `SELECT id, user_id, user_name, purpose, ship, buy_value, sell_value, costs, repetitions, scu, distance, scu_grade, commodity, post_a, post_b, note, month, year, org_percent, created_at, updated_at, deleted_at, deleted_by_id FROM trading_runs WHERE deleted_at IS NULL`
|
||||
args := []any{}
|
||||
if f.Month > 0 {
|
||||
q += ` AND month=?`
|
||||
args = append(args, f.Month)
|
||||
}
|
||||
if f.Year > 0 {
|
||||
q += ` AND year=?`
|
||||
args = append(args, f.Year)
|
||||
}
|
||||
if strings.TrimSpace(f.UserID) != "" {
|
||||
q += ` AND user_id=?`
|
||||
args = append(args, f.UserID)
|
||||
}
|
||||
q += ` ORDER BY year DESC, month DESC, created_at DESC LIMIT ?`
|
||||
args = append(args, f.Limit)
|
||||
rows, err := s.db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []TradingRun
|
||||
for rows.Next() {
|
||||
t, err := scanTradingRun(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) SummarizeTradingRuns(month, year int) (*TradingSummary, error) {
|
||||
runs, err := s.ListTradingRuns(TradingListFilter{Month: month, Year: year, Limit: 100000})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sum := &TradingSummary{Month: month, Year: year, Count: len(runs)}
|
||||
traderMap := map[string]*TradingTraderSummary{}
|
||||
purposeMap := map[string]*TradingPurposeSummary{}
|
||||
for _, r := range runs {
|
||||
profit := tradingProfit(r)
|
||||
share := tradingOrgShare(r)
|
||||
scu := r.SCU * float64(r.Repetitions)
|
||||
costs := r.Costs * r.Repetitions
|
||||
distance := r.Distance * float64(r.Repetitions)
|
||||
sum.GrossProfit += profit
|
||||
sum.OrgShare += share
|
||||
sum.TotalSCU += scu
|
||||
sum.TotalCosts += costs
|
||||
sum.TotalDistance += distance
|
||||
|
||||
key := r.UserID
|
||||
if key == "" {
|
||||
key = r.UserName
|
||||
}
|
||||
tr := traderMap[key]
|
||||
if tr == nil {
|
||||
tr = &TradingTraderSummary{UserID: r.UserID, UserName: r.UserName}
|
||||
traderMap[key] = tr
|
||||
}
|
||||
tr.Profit += profit
|
||||
tr.OrgShare += share
|
||||
tr.SCU += scu
|
||||
tr.Runs++
|
||||
|
||||
ps := purposeMap[r.Purpose]
|
||||
if ps == nil {
|
||||
ps = &TradingPurposeSummary{Purpose: r.Purpose}
|
||||
purposeMap[r.Purpose] = ps
|
||||
}
|
||||
ps.Profit += profit
|
||||
ps.OrgShare += share
|
||||
ps.SCU += scu
|
||||
ps.Runs++
|
||||
}
|
||||
for _, tr := range traderMap {
|
||||
sum.TopTraders = append(sum.TopTraders, *tr)
|
||||
}
|
||||
for _, ps := range purposeMap {
|
||||
sum.PurposeSummary = append(sum.PurposeSummary, *ps)
|
||||
}
|
||||
sortTradingSummary(sum)
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
func tradingProfit(r TradingRun) int64 {
|
||||
return (r.SellValue - r.BuyValue - r.Costs) * r.Repetitions
|
||||
}
|
||||
|
||||
func tradingOrgShare(r TradingRun) int64 {
|
||||
profit := tradingProfit(r)
|
||||
if profit <= 0 || r.OrgPercent <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int64(math.Round(float64(profit) * r.OrgPercent))
|
||||
}
|
||||
|
||||
func sortTradingSummary(sum *TradingSummary) {
|
||||
sort.Slice(sum.TopTraders, func(i, j int) bool {
|
||||
if sum.TopTraders[i].Profit == sum.TopTraders[j].Profit {
|
||||
return sum.TopTraders[i].UserName < sum.TopTraders[j].UserName
|
||||
}
|
||||
return sum.TopTraders[i].Profit > sum.TopTraders[j].Profit
|
||||
})
|
||||
if len(sum.TopTraders) > 3 {
|
||||
sum.TopTraders = sum.TopTraders[:3]
|
||||
}
|
||||
sort.Slice(sum.PurposeSummary, func(i, j int) bool { return sum.PurposeSummary[i].Purpose < sum.PurposeSummary[j].Purpose })
|
||||
}
|
||||
|
||||
54
discord.go
54
discord.go
@@ -79,6 +79,7 @@ func (b *Bot) registerCommands() error {
|
||||
{Name: "lagerort_add", Description: "Admin/Team: legt einen Lagerort an", Options: []*discordgo.ApplicationCommandOption{{Name: "name", Description: "Name des Lagerorts", Type: discordgo.ApplicationCommandOptionString, Required: true}}},
|
||||
{Name: "lagerort_remove", Description: "Admin/Team: löscht einen leeren Lagerort", Options: []*discordgo.ApplicationCommandOption{{Name: "ort", Description: "Lagerort", Type: discordgo.ApplicationCommandOptionString, Required: true, Autocomplete: true}}},
|
||||
}
|
||||
commands = append(commands, tradingCommands()...)
|
||||
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)
|
||||
@@ -176,6 +177,14 @@ func (b *Bot) handleCommand(s *discordgo.Session, i *discordgo.InteractionCreate
|
||||
b.cmdStorageLocationAdd(s, i)
|
||||
case "lagerort_remove":
|
||||
b.cmdStorageLocationRemove(s, i)
|
||||
case "trading_add":
|
||||
b.cmdTradingAdd(s, i)
|
||||
case "trading_liste":
|
||||
b.cmdTradingList(s, i)
|
||||
case "trading_delete":
|
||||
b.cmdTradingDelete(s, i)
|
||||
case "trading_report":
|
||||
b.cmdTradingReport(s, i)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,25 +515,40 @@ func (b *Bot) cmdStorageLocationRemove(s *discordgo.Session, i *discordgo.Intera
|
||||
func (b *Bot) handleAutocomplete(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
cmd := i.ApplicationCommandData().Name
|
||||
focusedName, focusedValue := focusedOption(i)
|
||||
if focusedName != "ort" || (cmd != "lager_add" && cmd != "lager_remove" && cmd != "lagerort_remove") {
|
||||
b.respondAutocomplete(s, i, nil)
|
||||
|
||||
if focusedName == "ort" && (cmd == "lager_add" || cmd == "lager_remove" || cmd == "lagerort_remove") {
|
||||
if !b.isTeamOrAdmin(i) {
|
||||
b.respondAutocomplete(s, i, nil)
|
||||
return
|
||||
}
|
||||
locations, err := b.store.ListStorageLocations(focusedValue, 25)
|
||||
if err != nil {
|
||||
log.Println("location autocomplete failed:", err)
|
||||
b.respondAutocomplete(s, i, nil)
|
||||
return
|
||||
}
|
||||
choices := make([]*discordgo.ApplicationCommandOptionChoice, 0, len(locations))
|
||||
for _, loc := range locations {
|
||||
choices = append(choices, &discordgo.ApplicationCommandOptionChoice{Name: loc.Name, Value: loc.Name})
|
||||
}
|
||||
b.respondAutocomplete(s, i, choices)
|
||||
return
|
||||
}
|
||||
if !b.isTeamOrAdmin(i) {
|
||||
b.respondAutocomplete(s, i, nil)
|
||||
|
||||
if cmd == "trading_add" && (focusedName == "schiff" || focusedName == "ware") {
|
||||
if !b.canUseTrading(i) {
|
||||
b.respondAutocomplete(s, i, nil)
|
||||
return
|
||||
}
|
||||
if focusedName == "schiff" {
|
||||
b.respondAutocomplete(s, i, autocompleteFromList(b.cfg.TradingShips, focusedValue, 25))
|
||||
return
|
||||
}
|
||||
b.respondAutocomplete(s, i, autocompleteFromList(b.cfg.TradingCommodities, focusedValue, 25))
|
||||
return
|
||||
}
|
||||
locations, err := b.store.ListStorageLocations(focusedValue, 25)
|
||||
if err != nil {
|
||||
log.Println("location autocomplete failed:", err)
|
||||
b.respondAutocomplete(s, i, nil)
|
||||
return
|
||||
}
|
||||
choices := make([]*discordgo.ApplicationCommandOptionChoice, 0, len(locations))
|
||||
for _, loc := range locations {
|
||||
choices = append(choices, &discordgo.ApplicationCommandOptionChoice{Name: loc.Name, Value: loc.Name})
|
||||
}
|
||||
b.respondAutocomplete(s, i, choices)
|
||||
|
||||
b.respondAutocomplete(s, i, nil)
|
||||
}
|
||||
|
||||
func (b *Bot) respondAutocomplete(s *discordgo.Session, i *discordgo.InteractionCreate, choices []*discordgo.ApplicationCommandOptionChoice) {
|
||||
|
||||
75
models.go
75
models.go
@@ -1,6 +1,9 @@
|
||||
package main
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusOpen = "open"
|
||||
@@ -75,3 +78,73 @@ type StorageLocation struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
TradingPurposeTrade = "trade"
|
||||
TradingPurposeHauling = "hauling"
|
||||
TradingPurposeEvent = "event"
|
||||
TradingPurposeIntern = "intern"
|
||||
)
|
||||
|
||||
type TradingRun struct {
|
||||
ID int64
|
||||
UserID string
|
||||
UserName string
|
||||
Purpose string
|
||||
Ship string
|
||||
BuyValue int64
|
||||
SellValue int64
|
||||
Costs int64
|
||||
Repetitions int64
|
||||
SCU float64
|
||||
Distance float64
|
||||
SCUGrade string
|
||||
Commodity string
|
||||
PostA string
|
||||
PostB string
|
||||
Note string
|
||||
Month int
|
||||
Year int
|
||||
OrgPercent float64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt sql.NullTime
|
||||
DeletedByID string
|
||||
}
|
||||
|
||||
type TradingListFilter struct {
|
||||
Month int
|
||||
Year int
|
||||
UserID string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type TradingSummary struct {
|
||||
Month int
|
||||
Year int
|
||||
Count int
|
||||
GrossProfit int64
|
||||
OrgShare int64
|
||||
TotalSCU float64
|
||||
TotalCosts int64
|
||||
TotalDistance float64
|
||||
TopTraders []TradingTraderSummary
|
||||
PurposeSummary []TradingPurposeSummary
|
||||
}
|
||||
|
||||
type TradingTraderSummary struct {
|
||||
UserID string
|
||||
UserName string
|
||||
Profit int64
|
||||
OrgShare int64
|
||||
SCU float64
|
||||
Runs int
|
||||
}
|
||||
|
||||
type TradingPurposeSummary struct {
|
||||
Purpose string
|
||||
Profit int64
|
||||
OrgShare int64
|
||||
SCU float64
|
||||
Runs int
|
||||
}
|
||||
|
||||
334
trading_discord.go
Normal file
334
trading_discord.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
func tradingCommands() []*discordgo.ApplicationCommand {
|
||||
return []*discordgo.ApplicationCommand{
|
||||
{Name: "trading_add", Description: "Erfasst einen Trading-Run", Options: []*discordgo.ApplicationCommandOption{
|
||||
{Name: "zweck", Description: "Zweck des Runs", Type: discordgo.ApplicationCommandOptionString, Required: true, Choices: tradingPurposeChoices()},
|
||||
{Name: "schiff", Description: "Schiff", Type: discordgo.ApplicationCommandOptionString, Required: true, Autocomplete: true},
|
||||
{Name: "einkaufswert", Description: "Einkaufswert in aUEC", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(0)},
|
||||
{Name: "verkaufswert", Description: "Verkaufswert in aUEC", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(0)},
|
||||
{Name: "kosten", Description: "Kosten in aUEC", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(0)},
|
||||
{Name: "wiederholungen", Description: "Anzahl Wiederholungen", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(1)},
|
||||
{Name: "scu", Description: "Transportierte SCU je Wiederholung", Type: discordgo.ApplicationCommandOptionNumber, Required: true, MinValue: ptrFloat(0)},
|
||||
{Name: "distanz", Description: "Distanz je Wiederholung", Type: discordgo.ApplicationCommandOptionNumber, Required: true, MinValue: ptrFloat(0)},
|
||||
{Name: "scu_gr", Description: "SCU-Größe / Container-Größe", Type: discordgo.ApplicationCommandOptionString, Required: true},
|
||||
{Name: "ware", Description: "Ware", Type: discordgo.ApplicationCommandOptionString, Required: true, Autocomplete: true},
|
||||
{Name: "tradingpost_a", Description: "Start / Einkauf", Type: discordgo.ApplicationCommandOptionString, Required: true},
|
||||
{Name: "tradingpost_b", Description: "Ziel / Verkauf", Type: discordgo.ApplicationCommandOptionString, Required: true},
|
||||
{Name: "monat", Description: "Monat 1-12, Standard aktueller Monat", Type: discordgo.ApplicationCommandOptionInteger, Required: false, MinValue: ptrFloat(1), MaxValue: float64(12)},
|
||||
{Name: "jahr", Description: "Jahr, Standard aktuelles Jahr", Type: discordgo.ApplicationCommandOptionInteger, Required: false, MinValue: ptrFloat(2000), MaxValue: float64(2200)},
|
||||
{Name: "bemerkung", Description: "Optionale Bemerkung", Type: discordgo.ApplicationCommandOptionString, Required: false},
|
||||
}},
|
||||
{Name: "trading_liste", Description: "Zeigt Trading-Einträge mit Filter", Options: []*discordgo.ApplicationCommandOption{
|
||||
{Name: "monat", Description: "Monat 1-12", Type: discordgo.ApplicationCommandOptionInteger, Required: false, MinValue: ptrFloat(1), MaxValue: float64(12)},
|
||||
{Name: "jahr", Description: "Jahr", Type: discordgo.ApplicationCommandOptionInteger, Required: false, MinValue: ptrFloat(2000), MaxValue: float64(2200)},
|
||||
{Name: "nutzer", Description: "Discord-Nutzer", Type: discordgo.ApplicationCommandOptionUser, Required: false},
|
||||
{Name: "limit", Description: "Maximal 50", Type: discordgo.ApplicationCommandOptionInteger, Required: false, MinValue: ptrFloat(1), MaxValue: float64(50)},
|
||||
}},
|
||||
{Name: "trading_delete", Description: "Admin: löscht einen Trading-Eintrag", Options: []*discordgo.ApplicationCommandOption{
|
||||
{Name: "id", Description: "Trading-ID", Type: discordgo.ApplicationCommandOptionInteger, Required: true},
|
||||
}},
|
||||
{Name: "trading_report", Description: "Admin: postet die Monatsauswertung", Options: []*discordgo.ApplicationCommandOption{
|
||||
{Name: "monat", Description: "Monat 1-12", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(1), MaxValue: float64(12)},
|
||||
{Name: "jahr", Description: "Jahr", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(2000), MaxValue: float64(2200)},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func tradingPurposeChoices() []*discordgo.ApplicationCommandOptionChoice {
|
||||
return []*discordgo.ApplicationCommandOptionChoice{
|
||||
{Name: "Trade", Value: TradingPurposeTrade},
|
||||
{Name: "Hauling", Value: TradingPurposeHauling},
|
||||
{Name: "Event", Value: TradingPurposeEvent},
|
||||
{Name: "Intern", Value: TradingPurposeIntern},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) cmdTradingAdd(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !b.canUseTrading(i) {
|
||||
b.replyEphemeral(s, i, "Du hast keine Berechtigung, Trading-Runs einzutragen.")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
month := int(optIntDefault(i, "monat", int64(now.Month())))
|
||||
year := int(optIntDefault(i, "jahr", int64(now.Year())))
|
||||
purpose := optString(i, "zweck")
|
||||
ship := strings.TrimSpace(optString(i, "schiff"))
|
||||
commodity := strings.TrimSpace(optString(i, "ware"))
|
||||
if !validTradingPurpose(purpose) {
|
||||
b.replyEphemeral(s, i, "Ungültiger Zweck.")
|
||||
return
|
||||
}
|
||||
if ship == "" || commodity == "" {
|
||||
b.replyEphemeral(s, i, "Schiff und Ware sind Pflichtfelder.")
|
||||
return
|
||||
}
|
||||
if len(b.cfg.TradingShips) > 0 && !containsFold(b.cfg.TradingShips, ship) {
|
||||
b.replyEphemeral(s, i, "Dieses Schiff ist nicht in TRADING_SHIPS konfiguriert.")
|
||||
return
|
||||
}
|
||||
if len(b.cfg.TradingCommodities) > 0 && !containsFold(b.cfg.TradingCommodities, commodity) {
|
||||
b.replyEphemeral(s, i, "Diese Ware ist nicht in TRADING_COMMODITIES konfiguriert.")
|
||||
return
|
||||
}
|
||||
|
||||
run := &TradingRun{
|
||||
UserID: userID(i),
|
||||
UserName: userString(i),
|
||||
Purpose: purpose,
|
||||
Ship: ship,
|
||||
BuyValue: optInt(i, "einkaufswert"),
|
||||
SellValue: optInt(i, "verkaufswert"),
|
||||
Costs: optInt(i, "kosten"),
|
||||
Repetitions: optInt(i, "wiederholungen"),
|
||||
SCU: optFloat(i, "scu"),
|
||||
Distance: optFloat(i, "distanz"),
|
||||
SCUGrade: strings.TrimSpace(optString(i, "scu_gr")),
|
||||
Commodity: commodity,
|
||||
PostA: strings.TrimSpace(optString(i, "tradingpost_a")),
|
||||
PostB: strings.TrimSpace(optString(i, "tradingpost_b")),
|
||||
Note: strings.TrimSpace(optString(i, "bemerkung")),
|
||||
Month: month,
|
||||
Year: year,
|
||||
OrgPercent: b.cfg.TradingOrgSharePercent[purpose],
|
||||
}
|
||||
id, err := b.store.CreateTradingRun(run)
|
||||
if err != nil {
|
||||
b.replyEphemeral(s, i, "Trading-Run konnte nicht gespeichert werden: "+err.Error())
|
||||
return
|
||||
}
|
||||
run.ID = id
|
||||
b.audit(fmt.Sprintf("Trading-Run #%d eingetragen durch %s: %s, Gewinn %s aUEC, Orga-Anteil %s aUEC", id, userString(i), run.Commodity, formatInt(tradingProfit(*run)), formatInt(tradingOrgShare(*run))))
|
||||
b.replyEphemeral(s, i, fmt.Sprintf("Trading-Run #%d gespeichert. Gewinn: %s aUEC, Orga-Anteil: %s aUEC.", id, formatInt(tradingProfit(*run)), formatInt(tradingOrgShare(*run))))
|
||||
}
|
||||
|
||||
func (b *Bot) cmdTradingList(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !b.canUseTrading(i) {
|
||||
b.replyEphemeral(s, i, "Du hast keine Berechtigung, Trading-Runs anzuzeigen.")
|
||||
return
|
||||
}
|
||||
filter := TradingListFilter{
|
||||
Month: int(optInt(i, "monat")),
|
||||
Year: int(optInt(i, "jahr")),
|
||||
Limit: int(optIntDefault(i, "limit", 20)),
|
||||
}
|
||||
if u := optUser(i, s, "nutzer"); u != nil {
|
||||
filter.UserID = u.ID
|
||||
}
|
||||
if filter.UserID == "" && !b.isTeamOrAdmin(i) {
|
||||
filter.UserID = userID(i)
|
||||
}
|
||||
runs, err := b.store.ListTradingRuns(filter)
|
||||
if err != nil {
|
||||
b.replyEphemeral(s, i, err.Error())
|
||||
return
|
||||
}
|
||||
if len(runs) == 0 {
|
||||
b.replyEphemeral(s, i, "Keine Trading-Einträge gefunden.")
|
||||
return
|
||||
}
|
||||
b.replyEphemeral(s, i, "Trading-Einträge\n"+tradingTable(runs))
|
||||
}
|
||||
|
||||
func (b *Bot) cmdTradingDelete(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !b.isAdmin(i) {
|
||||
b.replyEphemeral(s, i, "Nur Admins dürfen Trading-Einträge löschen.")
|
||||
return
|
||||
}
|
||||
id := optInt(i, "id")
|
||||
run, err := b.store.DeleteTradingRun(id, userID(i))
|
||||
if err != nil {
|
||||
b.replyEphemeral(s, i, "Eintrag konnte nicht gelöscht werden: "+err.Error())
|
||||
return
|
||||
}
|
||||
b.audit(fmt.Sprintf("Trading-Run #%d gelöscht durch %s. Ursprünglicher Nutzer: %s", id, userString(i), run.UserName))
|
||||
b.replyEphemeral(s, i, fmt.Sprintf("Trading-Run #%d wurde gelöscht.", id))
|
||||
}
|
||||
|
||||
func (b *Bot) cmdTradingReport(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !b.isAdmin(i) {
|
||||
b.replyEphemeral(s, i, "Nur Admins dürfen Trading-Reports posten.")
|
||||
return
|
||||
}
|
||||
month := int(optInt(i, "monat"))
|
||||
year := int(optInt(i, "jahr"))
|
||||
summary, err := b.store.SummarizeTradingRuns(month, year)
|
||||
if err != nil {
|
||||
b.replyEphemeral(s, i, "Report konnte nicht erstellt werden: "+err.Error())
|
||||
return
|
||||
}
|
||||
if summary.Count == 0 {
|
||||
b.replyEphemeral(s, i, "Für diesen Monat gibt es keine Trading-Einträge.")
|
||||
return
|
||||
}
|
||||
channelID := b.cfg.TradingReportChannelID
|
||||
if channelID == "" {
|
||||
channelID = b.cfg.InternalOrderChannelID
|
||||
}
|
||||
_, err = s.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{Embeds: []*discordgo.MessageEmbed{b.tradingReportEmbed(summary)}})
|
||||
if err != nil {
|
||||
b.replyEphemeral(s, i, "Report konnte nicht gepostet werden: "+err.Error())
|
||||
return
|
||||
}
|
||||
b.audit(fmt.Sprintf("Trading-Report %02d/%d gepostet durch %s", month, year, userString(i)))
|
||||
b.replyEphemeral(s, i, fmt.Sprintf("Trading-Report %02d/%d wurde gepostet.", month, year))
|
||||
}
|
||||
|
||||
func (b *Bot) tradingReportEmbed(sum *TradingSummary) *discordgo.MessageEmbed {
|
||||
fields := []*discordgo.MessageEmbedField{
|
||||
{Name: "Einträge", Value: fmt.Sprintf("%d", sum.Count), Inline: true},
|
||||
{Name: "Gewinn gesamt", Value: formatAUEC(sum.GrossProfit), Inline: true},
|
||||
{Name: "Orga-Anteil", Value: formatAUEC(sum.OrgShare), Inline: true},
|
||||
{Name: "Transportierte SCU", Value: formatAmount(sum.TotalSCU), Inline: true},
|
||||
{Name: "Kosten gesamt", Value: formatAUEC(sum.TotalCosts), Inline: true},
|
||||
{Name: "Distanz gesamt", Value: formatAmount(sum.TotalDistance), Inline: true},
|
||||
{Name: "Top 3 Trader", Value: topTraderText(sum.TopTraders), Inline: false},
|
||||
{Name: "Nach Zweck", Value: purposeSummaryText(sum.PurposeSummary), Inline: false},
|
||||
}
|
||||
return &discordgo.MessageEmbed{Title: fmt.Sprintf("Trading-Auswertung %02d/%d", sum.Month, sum.Year), Description: "Monatliche Zusammenfassung der erfassten Trading-Runs.", Color: 0x2ecc71, Fields: fields, Timestamp: time.Now().UTC().Format(time.RFC3339)}
|
||||
}
|
||||
|
||||
func topTraderText(items []TradingTraderSummary) string {
|
||||
if len(items) == 0 {
|
||||
return "Keine Daten"
|
||||
}
|
||||
var lines []string
|
||||
for idx, t := range items {
|
||||
name := t.UserName
|
||||
if t.UserID != "" {
|
||||
name = fmt.Sprintf("<@%s>", t.UserID)
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%d. %s — Gewinn %s, Orga %s, SCU %s, Runs %d", idx+1, name, formatAUEC(t.Profit), formatAUEC(t.OrgShare), formatAmount(t.SCU), t.Runs))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func purposeSummaryText(items []TradingPurposeSummary) string {
|
||||
if len(items) == 0 {
|
||||
return "Keine Daten"
|
||||
}
|
||||
var lines []string
|
||||
for _, p := range items {
|
||||
lines = append(lines, fmt.Sprintf("%s — Gewinn %s, Orga %s, SCU %s, Runs %d", tradingPurposeLabel(p.Purpose), formatAUEC(p.Profit), formatAUEC(p.OrgShare), formatAmount(p.SCU), p.Runs))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func tradingTable(runs []TradingRun) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("```text\n")
|
||||
b.WriteString(fmt.Sprintf("%-4s %-7s %-15s %-9s %-14s %-12s %-12s %-8s\n", "ID", "Monat", "Nutzer", "Zweck", "Ware", "Gewinn", "Orga", "SCU"))
|
||||
b.WriteString(strings.Repeat("-", 90) + "\n")
|
||||
for _, r := range runs {
|
||||
line := fmt.Sprintf("%-4d %02d/%-4d %-15s %-9s %-14s %-12s %-12s %-8s\n", r.ID, r.Month, r.Year, truncateText(r.UserName, 15), tradingPurposeLabel(r.Purpose), truncateText(r.Commodity, 14), formatInt(tradingProfit(r)), formatInt(tradingOrgShare(r)), formatAmount(r.SCU*float64(r.Repetitions)))
|
||||
if b.Len()+len(line)+4 > 1900 {
|
||||
b.WriteString("… Ausgabe gekürzt\n")
|
||||
break
|
||||
}
|
||||
b.WriteString(line)
|
||||
}
|
||||
b.WriteString("```")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (b *Bot) canUseTrading(i *discordgo.InteractionCreate) bool {
|
||||
return b.isTeamOrAdmin(i) || hasAnyRole(i, b.cfg.TradingRoleIDs)
|
||||
}
|
||||
|
||||
func validTradingPurpose(v string) bool {
|
||||
switch v {
|
||||
case TradingPurposeTrade, TradingPurposeHauling, TradingPurposeEvent, TradingPurposeIntern:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func tradingPurposeLabel(v string) string {
|
||||
switch v {
|
||||
case TradingPurposeTrade:
|
||||
return "Trade"
|
||||
case TradingPurposeHauling:
|
||||
return "Hauling"
|
||||
case TradingPurposeEvent:
|
||||
return "Event"
|
||||
case TradingPurposeIntern:
|
||||
return "Intern"
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func formatAUEC(v int64) string { return formatInt(v) + " aUEC" }
|
||||
|
||||
func formatInt(v int64) string {
|
||||
neg := v < 0
|
||||
if neg {
|
||||
v = -v
|
||||
}
|
||||
s := fmt.Sprintf("%d", v)
|
||||
for i := len(s) - 3; i > 0; i -= 3 {
|
||||
s = s[:i] + "." + s[i:]
|
||||
}
|
||||
if neg {
|
||||
return "-" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func containsFold(values []string, needle string) bool {
|
||||
needle = strings.TrimSpace(needle)
|
||||
for _, v := range values {
|
||||
if strings.EqualFold(strings.TrimSpace(v), needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func autocompleteFromList(values []string, query string, limit int) []*discordgo.ApplicationCommandOptionChoice {
|
||||
query = strings.ToLower(strings.TrimSpace(query))
|
||||
var filtered []string
|
||||
for _, v := range values {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if query == "" || strings.Contains(strings.ToLower(v), query) {
|
||||
filtered = append(filtered, v)
|
||||
}
|
||||
}
|
||||
sort.Slice(filtered, func(i, j int) bool { return strings.ToLower(filtered[i]) < strings.ToLower(filtered[j]) })
|
||||
if limit <= 0 || limit > 25 {
|
||||
limit = 25
|
||||
}
|
||||
if len(filtered) > limit {
|
||||
filtered = filtered[:limit]
|
||||
}
|
||||
choices := make([]*discordgo.ApplicationCommandOptionChoice, 0, len(filtered))
|
||||
for _, v := range filtered {
|
||||
choices = append(choices, &discordgo.ApplicationCommandOptionChoice{Name: v, Value: v})
|
||||
}
|
||||
return choices
|
||||
}
|
||||
|
||||
func optUser(i *discordgo.InteractionCreate, s *discordgo.Session, name string) *discordgo.User {
|
||||
for _, o := range i.ApplicationCommandData().Options {
|
||||
if o.Name == name {
|
||||
return o.UserValue(s)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func roundMoney(v float64) int64 { return int64(math.Round(v)) }
|
||||
Reference in New Issue
Block a user