refactor: bots
This commit is contained in:
parent
f46c73511a
commit
434f5a82bb
10 changed files with 223 additions and 65 deletions
2
go.mod
2
go.mod
|
|
@ -3,7 +3,7 @@ module codeberg.org/nextgo/dbots
|
|||
go 1.25.8
|
||||
|
||||
require (
|
||||
codeberg.org/ungo/env v0.0.0-20260315114019-c4fbd9390cb3
|
||||
codeberg.org/ungo/env v0.0.0-20260328142946-76f69daf34a3
|
||||
codeberg.org/ungo/gonsole v0.1.0
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/httplog/v3 v3.3.0
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -1,5 +1,7 @@
|
|||
codeberg.org/ungo/env v0.0.0-20260315114019-c4fbd9390cb3 h1:Xn8IiW5uYGajGqYPXU0kS8zXxqRs5E/MTfYjm0O1KrI=
|
||||
codeberg.org/ungo/env v0.0.0-20260315114019-c4fbd9390cb3/go.mod h1:pXfrNASG7JyxL30Zof3b1vbpd1dsHePTh3zGfPFgJKs=
|
||||
codeberg.org/ungo/env v0.0.0-20260328142946-76f69daf34a3 h1:k0NM+1XP3ebvfTvZfiHcyEZc0Drci5oxjZjE7L/xDdE=
|
||||
codeberg.org/ungo/env v0.0.0-20260328142946-76f69daf34a3/go.mod h1:pXfrNASG7JyxL30Zof3b1vbpd1dsHePTh3zGfPFgJKs=
|
||||
codeberg.org/ungo/gonsole v0.1.0 h1:QE/qpSyovejIXzIh29tzmrwgDWfaKUqNTCMZPJEDfvY=
|
||||
codeberg.org/ungo/gonsole v0.1.0/go.mod h1:xPHtFAI4CeujxpwhkiVrRa6pMvWYtaNsC+jJIib5EgA=
|
||||
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
|
||||
|
|
|
|||
|
|
@ -6,9 +6,25 @@ import (
|
|||
)
|
||||
|
||||
type Config struct {
|
||||
DatabaseURL string `env:"DATABASE_URL,required"`
|
||||
Database DatabaseConfig `env:"DATABASE_"`
|
||||
Server ServerConfig `env:"SERVER_"`
|
||||
Discord DiscordConfig `env:"DISCORD_"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
RedisURL string `env:"REDIS_URL"`
|
||||
PostgresURL string `env:"POSTGRES_URL,required"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `env:"PORT,default=8080"`
|
||||
DiscordToken string `env:"DISCORD_TOKEN"`
|
||||
Address string `env:"ADDRESS,default=127.0.0.1"`
|
||||
}
|
||||
|
||||
type DiscordConfig struct {
|
||||
ClientID string `env:"CLIENT_ID,required"`
|
||||
ClientSecret string `env:"CLIENT_SECRET,required"`
|
||||
RedirectURI string `env:"REDIRECT_URI,default=http://localhost:8080/auth/callback"`
|
||||
}
|
||||
|
||||
func LoadConfig() Config {
|
||||
|
|
|
|||
163
internal/discord/client.go
Normal file
163
internal/discord/client.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const baseURL = "https://discord.com/api/v10"
|
||||
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Discriminator string `json:"discriminator"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Email *string `json:"email"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
type ApplicationRPCResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Description string `json:"description"`
|
||||
Summary string `json:"summary"`
|
||||
IsMonetized bool `json:"is_monetized"`
|
||||
IsVerified bool `json:"is_verified"`
|
||||
IsDiscoverable bool `json:"is_discoverable"`
|
||||
Hook bool `json:"hook"`
|
||||
GuildID string `json:"guild_id"`
|
||||
StorefrontAvailable bool `json:"storefront_available"`
|
||||
BotPublic bool `json:"bot_public"`
|
||||
BotRequireCodeGrant bool `json:"bot_require_code_grant"`
|
||||
TermsOfServiceURL string `json:"terms_of_service_url"`
|
||||
PrivacyPolicyURL string `json:"privacy_policy_url"`
|
||||
CustomInstallURL string `json:"custom_install_url"`
|
||||
VerifyKey string `json:"verify_key"`
|
||||
Flags int `json:"flags"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
redirectURI string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(clientID, clientSecret, redirectURI string) *Client {
|
||||
return &Client{
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
redirectURI: redirectURI,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// AuthURL returns the Discord OAuth2 authorization URL.
|
||||
// state should be a random, unguessable string stored in the session.
|
||||
func (c *Client) AuthURL(state string) string {
|
||||
v := url.Values{}
|
||||
v.Set("client_id", c.clientID)
|
||||
v.Set("redirect_uri", c.redirectURI)
|
||||
v.Set("response_type", "code")
|
||||
v.Set("scope", "identify email")
|
||||
v.Set("state", state)
|
||||
return baseURL + "/oauth2/authorize?" + v.Encode()
|
||||
}
|
||||
|
||||
// ExchangeCode exchanges an authorization code for an access token.
|
||||
func (c *Client) ExchangeCode(ctx context.Context, code string) (*TokenResponse, error) {
|
||||
body := url.Values{}
|
||||
body.Set("grant_type", "authorization_code")
|
||||
body.Set("code", code)
|
||||
body.Set("redirect_uri", c.redirectURI)
|
||||
|
||||
return c.tokenRequest(ctx, body)
|
||||
}
|
||||
|
||||
// RefreshToken exchanges a refresh token for a new access token.
|
||||
func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*TokenResponse, error) {
|
||||
body := url.Values{}
|
||||
body.Set("grant_type", "refresh_token")
|
||||
body.Set("refresh_token", refreshToken)
|
||||
|
||||
return c.tokenRequest(ctx, body)
|
||||
}
|
||||
|
||||
// GetCurrentUser fetches the authenticated user's profile.
|
||||
func (c *Client) GetCurrentUser(ctx context.Context, accessToken string) (*User, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/users/@me", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
var user User
|
||||
if err := c.do(req, &user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetApplication fetches public metadata for a bot application.
|
||||
func (c *Client) GetApplication(ctx context.Context, id string) (*ApplicationRPCResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
fmt.Sprintf("%s/applications/%s/rpc", baseURL, id), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var app ApplicationRPCResponse
|
||||
if err := c.do(req, &app); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &app, nil
|
||||
}
|
||||
|
||||
func (c *Client) tokenRequest(ctx context.Context, body url.Values) (*TokenResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
baseURL+"/oauth2/token",
|
||||
strings.NewReader(body.Encode()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(c.clientID, c.clientSecret)
|
||||
|
||||
var t TokenResponse
|
||||
if err := c.do(req, &t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (c *Client) do(req *http.Request, out any) error {
|
||||
res, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
return fmt.Errorf("discord API error %d: %s", res.StatusCode, body)
|
||||
}
|
||||
|
||||
return json.NewDecoder(res.Body).Decode(out)
|
||||
}
|
||||
|
|
@ -8,12 +8,15 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
ErrBotNotFound = errors.New("bot not found")
|
||||
ErrBotAlreadyExists = errors.New("this bot has already been submitted")
|
||||
ErrSearchFailed = errors.New("no bots found fitting this filter")
|
||||
ErrInvalidID = errors.New("invalid discord id")
|
||||
ErrMainOwnerAsCoOwner = errors.New("you cannot set yourself as a co-owner")
|
||||
ErrBotNotExists = errors.New("bot does not exist inside Discord")
|
||||
ErrBotNotFound = errors.New("Bot not found")
|
||||
ErrBotAlreadyExists = errors.New("This bot has already been submitted")
|
||||
ErrSearchFailed = errors.New("No bots found fitting this filter")
|
||||
ErrMainOwnerAsCoOwner = errors.New("You cannot set yourself as a co-owner")
|
||||
ErrBotNotExists = errors.New("Bot does not exist inside Discord")
|
||||
|
||||
// validation
|
||||
ErrInvalidID = errors.New("Invalid Discord id")
|
||||
ErrInvalidOverview = errors.New("Bot overview must be between 15 and 50 characters long")
|
||||
)
|
||||
|
||||
type ErrResponse struct {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
|
||||
"codeberg.org/nextgo/dbots/internal/config"
|
||||
"codeberg.org/nextgo/dbots/internal/db"
|
||||
"codeberg.org/nextgo/dbots/internal/discord"
|
||||
customMiddlewares "codeberg.org/nextgo/dbots/internal/middleware"
|
||||
"codeberg.org/nextgo/dbots/services/admin"
|
||||
"codeberg.org/nextgo/dbots/services/bot"
|
||||
|
|
@ -41,7 +42,12 @@ func NewServer(queries *db.Queries, config config.Config) *Server {
|
|||
}
|
||||
|
||||
func (s *Server) Register() {
|
||||
botRouter := bot.NewRouter(s.queries, s.config)
|
||||
discordClient := discord.NewClient(
|
||||
s.config.Discord.ClientID,
|
||||
s.config.Discord.ClientSecret,
|
||||
s.config.Discord.RedirectURI,
|
||||
)
|
||||
botRouter := bot.NewRouter(s.queries, discordClient)
|
||||
adminRouter := admin.NewRouter(s.queries)
|
||||
|
||||
s.router.Mount("/bots", botRouter.Routes())
|
||||
|
|
|
|||
4
main.go
4
main.go
|
|
@ -22,7 +22,7 @@ func main() {
|
|||
config := config.LoadConfig()
|
||||
ctx := context.Background()
|
||||
|
||||
conn, err := pgxpool.New(ctx, config.DatabaseURL)
|
||||
conn, err := pgxpool.New(ctx, config.Database.PostgresURL)
|
||||
if err != nil {
|
||||
slog.Error("error connecting to postgres", "err", err)
|
||||
return
|
||||
|
|
@ -31,5 +31,5 @@ func main() {
|
|||
queries := db.New(conn)
|
||||
server := server.NewServer(queries, config)
|
||||
|
||||
server.Start(config.Port)
|
||||
server.Start(config.Server.Port)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,13 @@ 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/discord"
|
||||
"codeberg.org/nextgo/dbots/internal/errorutil"
|
||||
"codeberg.org/nextgo/dbots/internal/middleware"
|
||||
"codeberg.org/nextgo/dbots/internal/paginate"
|
||||
|
|
@ -43,51 +40,18 @@ type DiscordBot struct {
|
|||
|
||||
type Service struct {
|
||||
q *db.Queries
|
||||
c config.Config
|
||||
client *discord.Client
|
||||
}
|
||||
|
||||
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 NewService(q *db.Queries, client *discord.Client) *Service {
|
||||
return &Service{q: q, client: client}
|
||||
}
|
||||
|
||||
func (s *Service) Submit(ctx context.Context, data CreateBotRequest) (*db.Bot, error) {
|
||||
user := middleware.GetUser(ctx)
|
||||
apiBot, err := s.GetDiscordStats(data.ID)
|
||||
application, err := s.client.GetApplication(ctx, data.ID)
|
||||
if err != nil {
|
||||
return nil, errorutil.ErrBotNotExists
|
||||
return nil, errorutil.ErrBotNotExists // todo: some old bots have different client id (not the same as the user id)
|
||||
}
|
||||
|
||||
var count int32
|
||||
|
|
@ -96,11 +60,11 @@ func (s *Service) Submit(ctx context.Context, data CreateBotRequest) (*db.Bot, e
|
|||
Overview: &data.Overview,
|
||||
IsSlash: data.IsSlash,
|
||||
InstallContext: data.InstallContext,
|
||||
ImportedFrom: nil,
|
||||
Username: apiBot.Bot.Username,
|
||||
Avatar: &apiBot.Bot.Avatar,
|
||||
ImportedFrom: nil, // reserved for custom endpoint, should always be nil here ok??
|
||||
Username: application.Name,
|
||||
Avatar: &application.Icon,
|
||||
Description: &data.Description,
|
||||
GuildCount: &apiBot.Bot.ApproximateGuildCount,
|
||||
GuildCount: &count,
|
||||
InstallCount: &count,
|
||||
Prefix: data.Prefix,
|
||||
MainOwnerID: &user.ID,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ func (c *CreateBotRequest) Bind(req *http.Request) error {
|
|||
return errorutil.ErrInvalidID
|
||||
}
|
||||
|
||||
if len(c.Overview) < 15 || len(c.Overview) > 50 {
|
||||
return errorutil.ErrInvalidOverview
|
||||
}
|
||||
|
||||
// todo: proper checks idk
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import (
|
|||
"errors"
|
||||
"net/http"
|
||||
|
||||
"codeberg.org/nextgo/dbots/internal/config"
|
||||
"codeberg.org/nextgo/dbots/internal/db"
|
||||
"codeberg.org/nextgo/dbots/internal/discord"
|
||||
"codeberg.org/nextgo/dbots/internal/errorutil"
|
||||
"codeberg.org/nextgo/dbots/internal/middleware"
|
||||
"codeberg.org/nextgo/dbots/internal/paginate"
|
||||
|
|
@ -19,15 +19,15 @@ type Router struct {
|
|||
router chi.Router
|
||||
}
|
||||
|
||||
func NewRouter(q *db.Queries, c config.Config) *Router {
|
||||
func NewRouter(q *db.Queries, client *discord.Client) *Router {
|
||||
return &Router{
|
||||
bots: NewService(q, c),
|
||||
bots: NewService(q, client),
|
||||
router: chi.NewRouter(),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) Routes() http.Handler {
|
||||
r.router.Get("/", r.listBots) // todo: deprecate this
|
||||
r.router.Get("/search", r.searchBots)
|
||||
r.router.With(middleware.AuthGuardMiddleware).Post("/", r.submitBot)
|
||||
r.router.Route("/{botID}", func(router chi.Router) {
|
||||
router.Use(middleware.BotContext(r.bots.q))
|
||||
|
|
@ -73,7 +73,7 @@ func (r *Router) getBot(w http.ResponseWriter, req *http.Request) {
|
|||
render.JSON(w, req, bot)
|
||||
}
|
||||
|
||||
func (r *Router) listBots(w http.ResponseWriter, req *http.Request) {
|
||||
func (r *Router) searchBots(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
query := req.URL.Query().Get("q")
|
||||
p := paginate.ParseParams(req)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue