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 }