diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 35f6849..1c9ccbd 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -55,8 +55,4 @@ archives: format: zip changelog: - sort: asc - filters: - exclude: - - "^docs:" - - "^test:" + use: github-native diff --git a/README.md b/README.md index ab52800..2c73473 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Example results from the [@trusted](https://trusted.roto.lol/v1/steamids) list. Bot command list: - `!add [attributes]` Add the user to the master ban list. eg: `suspicious/cheater/bot`. If none are defined, it will use cheater by default. +- `!addproof ` Adds a entry in the users `proof` field. Can be any string/url. - `!del ` Remove the player from the master list - `!check ` Checks if the user exists in the database - `!count` Shows the current count of players tracked @@ -59,7 +60,7 @@ as [caddy](https://caddyserver.com/) that can provide automatic TLS certs for HT If you are using an internal docker network (recommended), ensure you also add the `--network your_network` flag to the run command. Take note that the container binds only to localhost in the example command shown `-p 127.0.0.1:8899:8899`, so you will not be able to access it remotely unless you remove the `127.0.0.1` or add a reverse proxy in front of it. When -using a reverse proxy, ensure that you set the `external_url` config option to the url that people can read your server +using a reverse proxy, ensure that you set the `external_url` config option to the url that people can access your server at. You can also use the `latest` image tag if you do not care about pinning to a specific version: `ghcr.io/leighmacdonald/tf2bdd:latest`. diff --git a/tf2bdd/bot.go b/tf2bdd/bot.go index 6d5af25..a89903a 100644 --- a/tf2bdd/bot.go +++ b/tf2bdd/bot.go @@ -8,6 +8,7 @@ import ( "fmt" "log/slog" "net/http" + "regexp" "slices" "strconv" "strings" @@ -134,16 +135,17 @@ func addEntry(ctx context.Context, database *sql.DB, sid steamid.SteamID, msg [] Time: time.Now().Unix(), }, SteamID: sid, + Proof: []string{}, } if err := AddPlayer(ctx, database, player, author); err != nil { - if err.Error() == "UNIQUE constraint failed: player.steamid" { + if errors.Is(err, ErrDuplicate) { return "", fmt.Errorf("duplicate steam id: %s", sid.String()) } slog.Error("Failed to add player", slog.String("error", err.Error())) - return "", fmt.Errorf("oops") + return "", err } return fmt.Sprintf("Added new entry successfully: %s", sid.String()), nil @@ -152,12 +154,30 @@ func addEntry(ctx context.Context, database *sql.DB, sid steamid.SteamID, msg [] func checkEntry(ctx context.Context, database *sql.DB, sid steamid.SteamID) (string, error) { player, errPlayer := getPlayer(ctx, database, sid) if errPlayer != nil { - return "", fmt.Errorf("steam id does not exist in database: %d", sid.Int64()) + if errors.Is(errPlayer, ErrNotFound) { + return "", fmt.Errorf("steam id does not exist in database: %d", sid.Int64()) + } + + return "", errPlayer + } + + var builder strings.Builder + builder.WriteString(fmt.Sprintf("\n:skull_crossbones: **%s is a confirmed baddie** :skull_crossbones:\n", player.LastSeen.PlayerName)) + builder.WriteString(fmt.Sprintf("**Attributes:** %s\n", strings.Join(player.Attributes, ", "))) + for idx, proof := range player.Proof { + if strings.HasPrefix(proof, "http") { + builder.WriteString(fmt.Sprintf("**Proof #%d:** <%s>\n", idx, proof)) + } else { + builder.WriteString(fmt.Sprintf("**Proof #%d:** %s\n", idx, proof)) + } + } + builder.WriteString(fmt.Sprintf("**Added on:** %s\n", player.CreatedOn.String())) + if player.Author > 0 { + builder.WriteString(fmt.Sprintf("**Author:** <@%d>\n", player.Author)) } + builder.WriteString(fmt.Sprintf("**Profile:** ", sid.String())) - return fmt.Sprintf(":skull_crossbones: %s is a confirmed baddie :skull_crossbones: "+ - "https://steamcommunity.com/profiles/%d \nAttributes: %s\nAuthor: <@%d>\nCreated: %s", - player.LastSeen.PlayerName, sid.Int64(), strings.Join(player.Attributes, ","), player.Author, player.CreatedOn.String()), nil + return builder.String(), nil } func getSteamid(sid steamid.SteamID) string { @@ -271,24 +291,32 @@ func deleteEntry(ctx context.Context, database *sql.DB, sid steamid.SteamID) (st return fmt.Sprintf("Dropped entry successfully: %s", sid.String()), nil } +func trimInputString(value string) string { + return strings.TrimSpace(regexp.MustCompile(`\s+`).ReplaceAllString(value, " ")) +} + func messageCreate(ctx context.Context, database *sql.DB, config Config) func(*discordgo.Session, *discordgo.MessageCreate) { return func(session *discordgo.Session, message *discordgo.MessageCreate) { // Ignore all messages created by the bot itself if message.Author.ID == session.State.User.ID { return } - msg := strings.Split(strings.ToLower(message.Content), " ") + msg := strings.Split(trimInputString(message.Content), " ") + command := strings.ToLower(msg[0]) minArgs := map[string]int{ - "!del": 2, - "!check": 2, - "!add": 2, - "!steamid": 2, - "!import": 1, - "!count": 1, + "!del": 2, + "!check": 2, + "!add": 2, + "!steamid": 2, + "!import": 1, + "!count": 1, + "!addproof": 3, } - argCount, found := minArgs[msg[0]] + argCount, found := minArgs[command] if !found { + sendMsg(session, message, fmt.Sprintf("unknown command: %s", command)) + return } @@ -306,7 +334,7 @@ func messageCreate(ctx context.Context, database *sql.DB, config Config) func(*d return } - if !allowed && msg[0] != "!steamid" && msg[0] != "!count" { + if !allowed && command != "!steamid" && command != "!count" { sendMsg(session, message, "Unauthorized") return @@ -317,13 +345,14 @@ func messageCreate(ctx context.Context, database *sql.DB, config Config) func(*d resolveCtx, cancel := context.WithTimeout(ctx, time.Second*10) defer cancel() - userSid, errSid := steamid.Resolve(resolveCtx, msg[1]) + idStr := msg[1] + userSid, errSid := steamid.Resolve(resolveCtx, idStr) if errSid != nil { - sendMsg(session, message, fmt.Sprintf("Cannot resolve steam id: %s", msg[1])) + sendMsg(session, message, fmt.Sprintf("Cannot resolve steam id: %s", idStr)) return } else if !userSid.Valid() { - sendMsg(session, message, fmt.Sprintf("Invalid SteamID: %s", msg[1])) + sendMsg(session, message, fmt.Sprintf("Invalid SteamID: %s", idStr)) return } @@ -341,6 +370,8 @@ func messageCreate(ctx context.Context, database *sql.DB, config Config) func(*d response, cmdErr = deleteEntry(ctx, database, sid) case "!check": response, cmdErr = checkEntry(ctx, database, sid) + case "!addproof": + response, cmdErr = addProof(ctx, database, sid, trimInputString(strings.Join(msg[2:], " "))) case "!add": author, errAuthor := strconv.ParseInt(message.Author.ID, 10, 64) if errAuthor != nil { @@ -367,6 +398,23 @@ func messageCreate(ctx context.Context, database *sql.DB, config Config) func(*d } } +func addProof(ctx context.Context, database *sql.DB, sid steamid.SteamID, proof string) (string, error) { + player, errPlayer := getPlayer(ctx, database, sid) + if errPlayer != nil { + return "", errPlayer + } + if slices.Contains(player.Proof, proof) { + return "", errors.New("duplicate proof provided") + } + player.Proof = append(player.Proof, proof) + + if errUpdate := updatePlayer(ctx, database, player); errUpdate != nil { + return "", errors.Join(errUpdate, errors.New("could not update player entry")) + } + + return "Added proof entry successfully", nil +} + func sendMsg(s *discordgo.Session, m *discordgo.MessageCreate, msg string) { if _, err := s.ChannelMessageSend(m.ChannelID, msg); err != nil { slog.Error(`Failed to send message "%s": %s`, slog.String("msg", msg), slog.String("error", err.Error())) diff --git a/tf2bdd/db.go b/tf2bdd/db.go index 8d1d3fc..d1f2d2a 100644 --- a/tf2bdd/db.go +++ b/tf2bdd/db.go @@ -3,8 +3,10 @@ package tf2bdd import ( "context" "database/sql" + "database/sql/driver" "embed" "errors" + "fmt" "log/slog" "strings" "time" @@ -13,6 +15,7 @@ import ( "github.com/golang-migrate/migrate/v4/database/sqlite" "github.com/golang-migrate/migrate/v4/source/iofs" "github.com/leighmacdonald/steamid/v4/steamid" + "github.com/ncruces/go-sqlite3" ) //go:embed migrations/*.sql @@ -25,8 +28,30 @@ var ( ErrStoreDriver = errors.New("failed to create db driver") ErrCreateMigration = errors.New("failed to create migrator") ErrPerformMigration = errors.New("failed to migrate database") + ErrDuplicate = errors.New("duplicate entry") + ErrNotFound = errors.New("entry not found") ) +func dbErr(err error) error { + if err == nil { + return nil + } + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + var sqliteErr *sqlite3.Error + if errors.As(err, &sqliteErr) { + switch { + case errors.Is(sqliteErr.Code(), sqlite3.CONSTRAINT): + return ErrDuplicate + default: + return fmt.Errorf("unhandled sqlite error: %w", err) + } + } + + return err +} + func OpenDB(dbPath string) (*sql.DB, error) { database, errOpen := sql.Open("sqlite3", dbPath) if errOpen != nil { @@ -75,8 +100,50 @@ func migrateDB(database *sql.DB) error { return nil } +const proofSep = "^^" + +type Proof []string + +func (p *Proof) Scan(value interface{}) error { + strVal, ok := value.(string) + if !ok { + return errors.New("invalid type") + } + if strVal == "" { + *p = []string{} + + return nil + } + + *p = strings.Split(strVal, proofSep) + + return nil +} + +func (p Proof) Value() (driver.Value, error) { + return strings.Join(p, proofSep), nil +} + +func updatePlayer(ctx context.Context, database *sql.DB, player Player) error { + const query = ` + UPDATE player + SET attributes = ?, + last_seen = ?, + last_name = ?, + author = ?, + proof = ? + WHERE steamid = ?` + + if _, errExec := database.ExecContext(ctx, query, strings.Join(player.Attributes, ","), player.LastSeen.Time, player.LastSeen.PlayerName, + player.Author, player.Proof, player.SteamID.Int64()); errExec != nil { + return errExec + } + + return nil +} + func getPlayer(ctx context.Context, database *sql.DB, steamID steamid.SteamID) (Player, error) { - const query = `SELECT steamid, attributes, last_seen, last_name, author, created_on FROM player WHERE steamid = ?` + const query = `SELECT steamid, attributes, last_seen, last_name, author, created_on, proof FROM player WHERE steamid = ?` var ( player Player @@ -85,12 +152,13 @@ func getPlayer(ctx context.Context, database *sql.DB, steamID steamid.SteamID) ( lastSeen int64 lastName string createdOn int64 + proof Proof ) if errScan := database. QueryRowContext(ctx, query, steamID.Int64()). - Scan(&sid, &attrs, &lastSeen, &lastName, &player.Author, &createdOn); errScan != nil { - return Player{}, errScan + Scan(&sid, &attrs, &lastSeen, &lastName, &player.Author, &createdOn, &proof); errScan != nil { + return Player{}, dbErr(errScan) } player.CreatedOn = time.Unix(createdOn, 0) @@ -100,12 +168,13 @@ func getPlayer(ctx context.Context, database *sql.DB, steamID steamid.SteamID) ( PlayerName: lastName, Time: lastSeen, } + player.Proof = proof return player, nil } func getPlayers(ctx context.Context, db *sql.DB) ([]Player, error) { - const query = `SELECT steamid, attributes, last_seen, last_name, author, created_on FROM player` + const query = `SELECT steamid, attributes, last_seen, last_name, author, created_on, proof FROM player` rows, err := db.QueryContext(ctx, query) if err != nil { @@ -128,9 +197,10 @@ func getPlayers(ctx context.Context, db *sql.DB) ([]Player, error) { lastSeen int64 lastName string createdOn int64 + proof Proof ) - if errScan := rows.Scan(&sid, &attrs, &lastSeen, &lastName, &player.Author, &createdOn); errScan != nil { + if errScan := rows.Scan(&sid, &attrs, &lastSeen, &lastName, &player.Author, &createdOn, &proof); errScan != nil { return nil, errors.Join(errScan, errors.New("error scanning player row")) } @@ -141,6 +211,7 @@ func getPlayers(ctx context.Context, db *sql.DB) ([]Player, error) { PlayerName: lastName, Time: lastSeen, } + player.Proof = proof players = append(players, player) } @@ -154,8 +225,8 @@ func getPlayers(ctx context.Context, db *sql.DB) ([]Player, error) { func AddPlayer(ctx context.Context, db *sql.DB, player Player, author int64) error { const query = ` - INSERT INTO player (steamid, attributes, last_seen, last_name, author, created_on) - VALUES(?, ?, ?, ?, ?, ?)` + INSERT INTO player (steamid, attributes, last_seen, last_name, author, created_on, proof) + VALUES(?, ?, ?, ?, ?, ?, ?)` if _, err := db.ExecContext(ctx, query, player.SteamID.Int64(), @@ -163,8 +234,9 @@ func AddPlayer(ctx context.Context, db *sql.DB, player Player, author int64) err player.LastSeen.Time, player.LastSeen.PlayerName, author, - int(time.Now().Unix())); err != nil { - return err + int(time.Now().Unix()), + player.Proof); err != nil { + return dbErr(err) } return nil diff --git a/tf2bdd/migrations/002_proof_column.down.sql b/tf2bdd/migrations/002_proof_column.down.sql new file mode 100644 index 0000000..929b534 --- /dev/null +++ b/tf2bdd/migrations/002_proof_column.down.sql @@ -0,0 +1 @@ +ALTER TABLE player DROP COLUMN proof; \ No newline at end of file diff --git a/tf2bdd/migrations/002_proof_column.up.sql b/tf2bdd/migrations/002_proof_column.up.sql new file mode 100644 index 0000000..2eb0e40 --- /dev/null +++ b/tf2bdd/migrations/002_proof_column.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE player +ADD COLUMN proof TEXT default ''; \ No newline at end of file diff --git a/tf2bdd/server.go b/tf2bdd/server.go index 76ef73a..9367c63 100644 --- a/tf2bdd/server.go +++ b/tf2bdd/server.go @@ -41,6 +41,7 @@ type Player struct { LastSeen LastSeen `json:"last_seen,omitempty"` Author int64 `json:"-"` CreatedOn time.Time `json:"-"` + Proof Proof `json:"proof"` } func handleGetSteamIDs(database *sql.DB, config Config) http.HandlerFunc {