3 Commits

Author SHA1 Message Date
466093e39b version fix 2026-02-19 12:07:03 +03:00
0e0f8a0813 v0.7.0; support for test server and local bot api 2026-02-19 11:49:04 +03:00
d84b0a1b55 small fixes 2026-02-18 14:05:36 +03:00
5 changed files with 121 additions and 71 deletions

16
bot.go
View File

@@ -15,7 +15,7 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo"
) )
type BotSettings struct { type BotOpts struct {
Token string Token string
Debug bool Debug bool
ErrorTemplate string ErrorTemplate string
@@ -24,10 +24,12 @@ type BotSettings struct {
LoggerBasePath string LoggerBasePath string
UseRequestLogger bool UseRequestLogger bool
WriteToFile bool WriteToFile bool
UseTestServer bool
APIUrl string
} }
func LoadSettingsFromEnv() *BotSettings { func LoadOptsFromEnv() *BotOpts {
return &BotSettings{ return &BotOpts{
Token: os.Getenv("TG_TOKEN"), Token: os.Getenv("TG_TOKEN"),
Debug: os.Getenv("DEBUG") == "true", Debug: os.Getenv("DEBUG") == "true",
ErrorTemplate: os.Getenv("ERROR_TEMPLATE"), ErrorTemplate: os.Getenv("ERROR_TEMPLATE"),
@@ -35,6 +37,8 @@ func LoadSettingsFromEnv() *BotSettings {
UpdateTypes: strings.Split(os.Getenv("UPDATE_TYPES"), ";"), UpdateTypes: strings.Split(os.Getenv("UPDATE_TYPES"), ";"),
UseRequestLogger: os.Getenv("USE_REQ_LOG") == "true", UseRequestLogger: os.Getenv("USE_REQ_LOG") == "true",
WriteToFile: os.Getenv("WRITE_TO_FILE") == "true", WriteToFile: os.Getenv("WRITE_TO_FILE") == "true",
UseTestServer: os.Getenv("USE_TEST_SERVER") == "true",
APIUrl: os.Getenv("API_URL"),
} }
} }
@@ -70,9 +74,10 @@ type Bot struct {
updateQueue *extypes.Queue[*tgapi.Update] updateQueue *extypes.Queue[*tgapi.Update]
} }
func NewBot(settings *BotSettings) *Bot { func NewBot(settings *BotOpts) *Bot {
updateQueue := extypes.CreateQueue[*tgapi.Update](256) updateQueue := extypes.CreateQueue[*tgapi.Update](256)
api := tgapi.NewAPI(settings.Token) apiOpts := tgapi.NewAPIOpts(settings.Token).SetAPIUrl(settings.APIUrl).UseTestServer(settings.UseTestServer)
api := tgapi.NewAPI(apiOpts)
bot := &Bot{ bot := &Bot{
updateOffset: 0, plugins: make([]Plugin, 0), debug: settings.Debug, errorTemplate: "%s", updateOffset: 0, plugins: make([]Plugin, 0), debug: settings.Debug, errorTemplate: "%s",
prefixes: settings.Prefixes, updateTypes: make([]tgapi.UpdateType, 0), runners: make([]Runner, 0), prefixes: settings.Prefixes, updateTypes: make([]tgapi.UpdateType, 0), runners: make([]Runner, 0),
@@ -237,7 +242,6 @@ func (b *Bot) Run() {
return return
} }
b.logger.Infoln("Executing runners...")
b.ExecRunners() b.ExecRunners()
b.logger.Infoln("Bot running. Press CTRL+C to exit.") b.logger.Infoln("Bot running. Press CTRL+C to exit.")

View File

@@ -1,6 +1,7 @@
package laniakea package laniakea
import ( import (
"errors"
"fmt" "fmt"
"strings" "strings"
@@ -35,6 +36,8 @@ func generateBotCommandForPlugin(pl Plugin) []tgapi.BotCommand {
return commands return commands
} }
var ErrTooManyCommands = errors.New("too many commands. max 100")
func (b *Bot) AutoGenerateCommands() error { func (b *Bot) AutoGenerateCommands() error {
_, err := b.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{}) _, err := b.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{})
if err != nil { if err != nil {
@@ -45,6 +48,9 @@ func (b *Bot) AutoGenerateCommands() error {
for _, pl := range b.plugins { for _, pl := range b.plugins {
commands = append(commands, generateBotCommandForPlugin(pl)...) commands = append(commands, generateBotCommandForPlugin(pl)...)
} }
if len(commands) > 100 {
return ErrTooManyCommands
}
privateChatsScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopePrivateType} privateChatsScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopePrivateType}
groupChatsScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopeGroupType} groupChatsScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopeGroupType}

View File

@@ -13,17 +13,49 @@ import (
"git.nix13.pw/scuroneko/slog" "git.nix13.pw/scuroneko/slog"
) )
type APIOpts struct {
token string
client *http.Client
useTestServer bool
apiUrl string
}
func NewAPIOpts(token string) *APIOpts {
return &APIOpts{token: token, client: nil, useTestServer: false, apiUrl: "https://api.telegram.org"}
}
func (opts *APIOpts) SetHTTPClient(client *http.Client) *APIOpts {
if client != nil {
opts.client = client
}
return opts
}
func (opts *APIOpts) UseTestServer(use bool) *APIOpts {
opts.useTestServer = use
return opts
}
func (opts *APIOpts) SetAPIUrl(apiUrl string) *APIOpts {
if apiUrl != "" {
opts.apiUrl = apiUrl
}
return opts
}
type API struct { type API struct {
token string token string
client *http.Client client *http.Client
Logger *slog.Logger Logger *slog.Logger
useTestServer bool
apiUrl string
} }
func NewAPI(token string) *API { func NewAPI(opts *APIOpts) *API {
l := slog.CreateLogger().Level(utils.GetLoggerLevel()).Prefix("API") l := slog.CreateLogger().Level(utils.GetLoggerLevel()).Prefix("API")
l.AddWriter(l.CreateJsonStdoutWriter()) l.AddWriter(l.CreateJsonStdoutWriter())
client := &http.Client{Timeout: time.Second * 45} client := opts.client
return &API{token, client, l} if client == nil {
client = &http.Client{Timeout: time.Second * 45}
}
return &API{opts.token, client, l, opts.useTestServer, opts.apiUrl}
} }
func (api *API) CloseApi() error { func (api *API) CloseApi() error {
return api.Logger.Close() return api.Logger.Close()
@@ -52,8 +84,12 @@ func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *API) (R,
} }
buf := bytes.NewBuffer(data) buf := bytes.NewBuffer(data)
u := fmt.Sprintf("https://api.telegram.org/bot%s/%s", api.token, r.method) methodPrefix := ""
req, err := http.NewRequestWithContext(ctx, "POST", u, buf) if api.useTestServer {
methodPrefix = "/test"
}
url := fmt.Sprintf("%s/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, r.method)
req, err := http.NewRequestWithContext(ctx, "POST", url, buf)
if err != nil { if err != nil {
return zero, err return zero, err
} }
@@ -61,25 +97,38 @@ func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *API) (R,
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString)) req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString))
api.Logger.Debugln("REQ", r.method, buf.String()) api.Logger.Debugln("REQ", api.apiUrl, r.method, buf.String())
res, err := api.client.Do(req) res, err := api.client.Do(req)
if err != nil { if err != nil {
return zero, err return zero, err
} }
defer res.Body.Close() defer func(Body io.ReadCloser) {
_ = Body.Close()
}(res.Body)
reader := io.LimitReader(res.Body, 10<<20) data, err = readBody(res.Body)
data, err = io.ReadAll(reader)
if err != nil { if err != nil {
return zero, err return zero, err
} }
api.Logger.Debugln("RES", r.method, string(data)) api.Logger.Debugln("RES", r.method, string(data))
if res.StatusCode != http.StatusOK { if res.StatusCode != http.StatusOK {
return zero, fmt.Errorf("unexpected status code: %d", res.StatusCode) return zero, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, string(data))
}
return parseBody[R](data)
}
func (r TelegramRequest[R, P]) Do(api *API) (R, error) {
return r.DoWithContext(context.Background(), api)
} }
func readBody(body io.ReadCloser) ([]byte, error) {
reader := io.LimitReader(body, 10<<20)
return io.ReadAll(reader)
}
func parseBody[R any](data []byte) (R, error) {
var zero R
var resp ApiResponse[R] var resp ApiResponse[R]
err = json.Unmarshal(data, &resp) err := json.Unmarshal(data, &resp)
if err != nil { if err != nil {
return zero, err return zero, err
} }
@@ -87,9 +136,4 @@ func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *API) (R,
return zero, fmt.Errorf("[%d] %s", resp.ErrorCode, resp.Description) return zero, fmt.Errorf("[%d] %s", resp.ErrorCode, resp.Description)
} }
return resp.Result, nil return resp.Result, nil
}
func (r TelegramRequest[R, P]) Do(api *API) (R, error) {
ctx := context.Background()
return r.DoWithContext(ctx, api)
} }

View File

@@ -3,9 +3,7 @@ package tgapi
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"path/filepath" "path/filepath"
@@ -66,39 +64,22 @@ func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile
} }
func (u UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader) (R, error) { func (u UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader) (R, error) {
var zero R var zero R
url := fmt.Sprintf("https://api.telegram.org/bot%s/%s", up.api.token, u.method)
buf := bytes.NewBuffer(nil) buf, contentType, err := prepareMultipart(u.files, u.params)
w := multipart.NewWriter(buf)
for _, file := range u.files {
fw, err := w.CreateFormFile(string(file.field), file.filename)
if err != nil {
_ = w.Close()
return zero, err
}
_, err = fw.Write(file.data)
if err != nil {
_ = w.Close()
return zero, err
}
}
err := utils.Encode(w, u.params)
if err != nil {
_ = w.Close()
return zero, err
}
err = w.Close()
if err != nil { if err != nil {
return zero, err return zero, err
} }
methodPrefix := ""
if up.api.useTestServer {
methodPrefix = "/test"
}
url := fmt.Sprintf("%s/bot%s%s/%s", up.api.apiUrl, up.api.token, methodPrefix, u.method)
req, err := http.NewRequestWithContext(ctx, "POST", url, buf) req, err := http.NewRequestWithContext(ctx, "POST", url, buf)
if err != nil { if err != nil {
return zero, err return zero, err
} }
req.Header.Set("Content-Type", w.FormDataContentType()) req.Header.Set("Content-Type", contentType)
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString)) req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString))
@@ -108,31 +89,46 @@ func (u UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader)
return zero, err return zero, err
} }
defer res.Body.Close() defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return zero, fmt.Errorf("unexpected status code: %d", res.StatusCode)
}
reader := io.LimitReader(res.Body, 10<<20) body, err := readBody(res.Body)
body, err := io.ReadAll(reader)
if err != nil {
return zero, err
}
up.logger.Debugln("UPLOADER RES", u.method, string(body)) up.logger.Debugln("UPLOADER RES", u.method, string(body))
if res.StatusCode != http.StatusOK {
return zero, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, string(body))
}
var resp ApiResponse[R] return parseBody[R](body)
err = json.Unmarshal(body, &resp)
if err != nil {
return zero, err
}
if !resp.Ok {
return zero, fmt.Errorf("[%d] %s", resp.ErrorCode, resp.Description)
}
return resp.Result, nil
} }
func (u UploaderRequest[R, P]) Do(up *Uploader) (R, error) { func (u UploaderRequest[R, P]) Do(up *Uploader) (R, error) {
return u.DoWithContext(context.Background(), up) return u.DoWithContext(context.Background(), up)
} }
func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, string, error) {
buf := bytes.NewBuffer(nil)
w := multipart.NewWriter(buf)
for _, file := range files {
fw, err := w.CreateFormFile(string(file.field), file.filename)
if err != nil {
_ = w.Close()
return buf, w.FormDataContentType(), err
}
_, err = fw.Write(file.data)
if err != nil {
_ = w.Close()
return buf, w.FormDataContentType(), err
}
}
err := utils.Encode(w, params)
if err != nil {
_ = w.Close()
return buf, w.FormDataContentType(), err
}
err = w.Close()
return buf, w.FormDataContentType(), err
}
func uploaderTypeByExt(filename string) UploaderFileType { func uploaderTypeByExt(filename string) UploaderFileType {
ext := filepath.Ext(filename) ext := filepath.Ext(filename)
switch ext { switch ext {

View File

@@ -1,8 +1,8 @@
package utils package utils
const ( const (
VersionString = "0.6.1" VersionString = "0.7.1"
VersionMajor = 0 VersionMajor = 0
VersionMinor = 6 VersionMinor = 7
VersionPatch = 1 VersionPatch = 1
) )