dbots/services/bot/bot.go

261 lines
6.9 KiB
Go

package bot
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"codeberg.org/nextgo/dbots/internal/config"
"codeberg.org/nextgo/dbots/internal/db"
"codeberg.org/nextgo/dbots/internal/errorutil"
"codeberg.org/nextgo/dbots/internal/middleware"
"codeberg.org/nextgo/dbots/internal/paginate"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DiscordApiBot struct {
Application DiscordApplication `json:"application"`
Bot DiscordBot `json:"bot"`
}
type DiscordApplication struct {
ID string `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
Description string `json:"description"`
IsVerified bool `json:"is_verified"`
BotPublic bool `json:"bot_public"`
}
type DiscordBot struct {
ID string `json:"id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
Bot bool `json:"bot"`
ApproximateGuildCount int32 `json:"approximate_guild_count"`
}
type Service struct {
q *db.Queries
c config.Config
}
func NewService(q *db.Queries, c config.Config) *Service {
return &Service{q: q, c: c}
}
// todo: finish
func (s *Service) GetDiscordStats(id string) (*DiscordApiBot, error) {
req, err := http.NewRequest(
http.MethodGet,
fmt.Sprintf("https://discord.com/api/v10/oauth2/authorize?client_id=%s&scope=bot", id),
nil,
) // todo: since this method is a bit "illegal", i should move to rpc endpoint (which does not provide guild count)
if err != nil {
return nil, err
}
req.Header.Add("Authorization", s.c.DiscordToken)
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
var bot DiscordApiBot
if err = json.Unmarshal(body, &bot); err != nil {
return nil, err
}
return &bot, nil
}
func (s *Service) Submit(ctx context.Context, data CreateBotRequest) (*db.Bot, error) {
user := middleware.GetUser(ctx)
apiBot, err := s.GetDiscordStats(data.ID)
if err != nil {
return nil, errorutil.ErrBotNotExists
}
var count int32
b, err := s.q.CreateBot(ctx, db.CreateBotParams{
ID: data.ID,
Overview: &data.Overview,
IsSlash: data.IsSlash,
InstallContext: data.InstallContext,
ImportedFrom: nil,
Username: apiBot.Bot.Username,
Avatar: &apiBot.Bot.Avatar,
Description: &data.Description,
GuildCount: &apiBot.Bot.ApproximateGuildCount,
InstallCount: &count,
Prefix: data.Prefix,
MainOwnerID: &user.ID,
})
if err != nil {
slog.Error("error submitting bot", "err", err, "bot_id", data.ID)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return nil, errorutil.ErrBotAlreadyExists
}
return nil, err
}
if err := s.addCoOwners(ctx, b.ID, user.ID, data.CoOwners); err != nil {
// Bot was created but co-owners failed. Log and surface the error.
// ideally this whole operation runs in a transaction but who cares lol.
slog.Error("error adding co-owners after submit", "err", err, "bot_id", b.ID)
// so if co-owners fail it's because the co-owner isn't registered in the botlist,
// however, i should handle this but i'm lazy as fuck. for now i will only output
// a silent error log instead of aborting the whole request.
}
return b, nil
}
// AddCoOwner adds a single co-owner to a bot.
// Only the main owner of the bot may call this.
func (s *Service) AddCoOwner(ctx context.Context, botID, coOwnerID string) error {
user := middleware.GetUser(ctx)
bot, err := s.q.GetBot(ctx, botID)
if err != nil {
return errorutil.ErrNotFound.Err
}
if bot.MainOwnerID == nil || *bot.MainOwnerID != user.ID {
return errorutil.ErrForbidden.Err
}
if err := validateCoOwner(coOwnerID, user.ID); err != nil {
return err
}
if err := s.q.AddBotCoOwner(ctx, db.AddBotCoOwnerParams{
BotID: botID,
UserID: coOwnerID,
}); err != nil {
slog.Error("error adding co-owner", "err", err, "bot_id", botID, "user_id", coOwnerID)
return err
}
return nil
}
// RemoveCoOwner removes a co-owner from a bot.
// Only the main owner of the bot may call this.
func (s *Service) RemoveCoOwner(ctx context.Context, botID, coOwnerID string) error {
user := middleware.GetUser(ctx)
bot, err := s.q.GetBot(ctx, botID)
if err != nil {
return errorutil.ErrNotFound.Err
}
if bot.MainOwnerID == nil || *bot.MainOwnerID != user.ID {
return errorutil.ErrForbidden.Err
}
if err := s.q.RemoveBotCoOwner(ctx, db.RemoveBotCoOwnerParams{
BotID: botID,
UserID: coOwnerID,
}); err != nil {
slog.Error("error removing co-owner", "err", err, "bot_id", botID, "user_id", coOwnerID)
return err
}
return nil
}
// ListCoOwners returns the users who are co-owners of a bot.
func (s *Service) ListCoOwners(ctx context.Context, botID string) ([]*db.User, error) {
owners, err := s.q.ListCoOwnersByBot(ctx, botID)
if err != nil {
slog.Error("error listing co-owners", "err", err, "bot_id", botID)
return nil, err
}
return owners, nil
}
func (s *Service) Get(ctx context.Context, id string) (*db.Bot, error) {
bot, err := s.q.GetBot(ctx, id)
if err != nil {
slog.Error("error getting bot", "err", err, "id", id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errorutil.ErrNotFound.Err
} else {
return nil, err
}
}
return bot, nil
}
func (s *Service) List(
ctx context.Context,
query string,
p paginate.Params,
) (paginate.Page[*db.Bot], error) {
total, err := s.q.CountBotsByUsername(ctx, db.CountBotsByUsernameParams{
Status: db.BotStatusApproved,
Query: query,
})
if err != nil {
slog.Error("error counting bots", "query", query, "err", err)
return paginate.Page[*db.Bot]{}, errorutil.ErrSearchFailed
}
bots, err := s.q.ListBotsByStatus(ctx, db.ListBotsByStatusParams{
Status: db.BotStatusApproved,
Limit: p.Limit,
Offset: p.Offset,
})
if err != nil {
slog.Error("error listing bots", "query", query, "err", err)
return paginate.Page[*db.Bot]{}, errorutil.ErrSearchFailed
}
return paginate.NewPage(bots, int(total), p), nil
}
// validateCoOwner checks that the given ID is a valid Discord snowflake
// and is not the same as the main owner.
func validateCoOwner(id, mainOwnerID string) error {
if _, err := strconv.ParseUint(id, 10, 64); err != nil {
return errorutil.ErrInvalidID
}
if id == mainOwnerID {
return errorutil.ErrMainOwnerAsCoOwner
}
return nil
}
// addCoOwners validates and inserts each co-owner for a bot.
// mainOwnerID is provided to reject attempts to set the main owner as a co-owner.
func (s *Service) addCoOwners(ctx context.Context, botID, mainOwnerID string, coOwnerIDs []string) error {
for _, id := range coOwnerIDs {
if err := validateCoOwner(id, mainOwnerID); err != nil {
return err
}
if err := s.q.AddBotCoOwner(ctx, db.AddBotCoOwnerParams{
BotID: botID,
UserID: id,
}); err != nil {
return fmt.Errorf("adding co-owner %s: %w", id, err)
}
}
return nil
}