REPOSITORY / ScuroNeko/Laniakea
Compare commits
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38309e74f6
|
||
|
|
c2f6406819
|
||
|
|
9e3450df31
|
||
|
|
effd26bd9a | ||
|
|
a7c8d68925 | ||
|
|
5f17b88787 | ||
|
|
6d6f5738cd | ||
|
|
fef718438a | ||
|
|
7f248fff62 |
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.0.2
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed long-polling stopping permanently when the HTTP client's internal timeout fired. The polling loop was checking `errors.Is(err, context.DeadlineExceeded)`, which matched HTTP client timeout errors (`*url.Error` wraps `context.DeadlineExceeded`), causing the goroutine to exit as if the bot context was canceled. The check is now `ctx.Err() != nil` so only a real context cancellation stops polling.
|
||||||
|
- Fixed the HTTP client timeout (45 s) being too close to the long-poll `getUpdates` timeout (30 s default), leaving insufficient margin for connection setup and response transfer. The client timeout is now derived from the configured `PollTimeout` plus a 60-second buffer.
|
||||||
|
|
||||||
|
## v1.0.1
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed webhook always accepting unauthenticated requests when `SecretToken` is not configured. A cryptographically random 32-byte token is now generated automatically when `SecretToken` is empty, so the webhook endpoint is always authenticated. The generated token is logged as a warning so the operator can record it.
|
||||||
|
- Fixed `tgapi.NewAPI` and `tgapi.NewUploader` not installing token redaction on their managed loggers. The bot token is now masked as `<TOKEN>` in debug output even when the `tgapi` package is used standalone without the `laniakea.Bot` wrapper.
|
||||||
|
|
||||||
## v1.0.0
|
## v1.0.0
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -166,21 +167,6 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
|
|||||||
limiter := utils.NewRateLimiter()
|
limiter := utils.NewRateLimiter()
|
||||||
limiter.SetGlobalRate(opts.RateLimit)
|
limiter.SetGlobalRate(opts.RateLimit)
|
||||||
|
|
||||||
apiOpts := tgapi.NewAPIOpts(opts.Token).
|
|
||||||
SetAPIURL(opts.APIURL).
|
|
||||||
UseTestServer(opts.UseTestServer).
|
|
||||||
SetLimiter(limiter).
|
|
||||||
SetDropRateLimitOverflow(opts.DropRateLimitOverflow).
|
|
||||||
SetLogFormat(opts.LogFormat).
|
|
||||||
SetLogFormatter(opts.LogFormatter)
|
|
||||||
api := tgapi.NewAPI(apiOpts)
|
|
||||||
uploader := tgapi.NewUploader(api)
|
|
||||||
|
|
||||||
prefixes := opts.Prefixes
|
|
||||||
if len(prefixes) == 0 {
|
|
||||||
prefixes = []string{"/"}
|
|
||||||
}
|
|
||||||
|
|
||||||
workers := 32
|
workers := 32
|
||||||
if opts.MaxWorkers > 0 {
|
if opts.MaxWorkers > 0 {
|
||||||
workers = opts.MaxWorkers
|
workers = opts.MaxWorkers
|
||||||
@@ -191,6 +177,25 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
|
|||||||
pollTimeout = opts.PollTimeout
|
pollTimeout = opts.PollTimeout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTTP client timeout must exceed pollTimeout to avoid spurious deadline
|
||||||
|
// errors that the polling loop would misinterpret as context cancellation.
|
||||||
|
httpTimeout := time.Duration(pollTimeout)*time.Second + 60*time.Second
|
||||||
|
apiOpts := tgapi.NewAPIOpts(opts.Token).
|
||||||
|
SetAPIURL(opts.APIURL).
|
||||||
|
UseTestServer(opts.UseTestServer).
|
||||||
|
SetLimiter(limiter).
|
||||||
|
SetDropRateLimitOverflow(opts.DropRateLimitOverflow).
|
||||||
|
SetLogFormat(opts.LogFormat).
|
||||||
|
SetLogFormatter(opts.LogFormatter).
|
||||||
|
SetHTTPClient(&http.Client{Timeout: httpTimeout})
|
||||||
|
api := tgapi.NewAPI(apiOpts)
|
||||||
|
uploader := tgapi.NewUploader(api)
|
||||||
|
|
||||||
|
prefixes := opts.Prefixes
|
||||||
|
if len(prefixes) == 0 {
|
||||||
|
prefixes = []string{"/"}
|
||||||
|
}
|
||||||
|
|
||||||
bot := &Bot[T]{
|
bot := &Bot[T]{
|
||||||
updateOffset: 0,
|
updateOffset: 0,
|
||||||
errorTemplate: "%s",
|
errorTemplate: "%s",
|
||||||
@@ -472,7 +477,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
|
|||||||
default:
|
default:
|
||||||
updates, err := bot.Updates(ctx)
|
updates, err := bot.Updates(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
if ctx.Err() != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
retryDelay, ok := pollRetryAfterDelay(err)
|
retryDelay, ok := pollRetryAfterDelay(err)
|
||||||
|
|||||||
+11
-6
@@ -137,6 +137,15 @@ func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOp
|
|||||||
if len(bot.plugins) == 0 {
|
if len(bot.plugins) == 0 {
|
||||||
return ErrNoPlugins
|
return ErrNoPlugins
|
||||||
}
|
}
|
||||||
|
autoSecret := ""
|
||||||
|
if opts.SecretToken == "" {
|
||||||
|
rndSecret, err := generateToken(32)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
opts.SecretToken = rndSecret
|
||||||
|
autoSecret = rndSecret
|
||||||
|
}
|
||||||
if opts.URL == "" {
|
if opts.URL == "" {
|
||||||
return ErrNoBotWebhookOptsURL
|
return ErrNoBotWebhookOptsURL
|
||||||
}
|
}
|
||||||
@@ -146,9 +155,6 @@ func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOp
|
|||||||
if err := validateWebhookPath(opts.Path, opts.UseStatusPath); err != nil {
|
if err := validateWebhookPath(opts.Path, opts.UseStatusPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if opts.UseStatusPath && opts.SecretToken == "" {
|
|
||||||
return ErrStatusPathSecretRequired
|
|
||||||
}
|
|
||||||
if err := validateWebhookTLSFiles(tlsFiles); err != nil {
|
if err := validateWebhookTLSFiles(tlsFiles); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -158,10 +164,9 @@ func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOp
|
|||||||
}
|
}
|
||||||
|
|
||||||
return bot.runWebhookRuntime(ctx, func(runCtx context.Context) error {
|
return bot.runWebhookRuntime(ctx, func(runCtx context.Context) error {
|
||||||
if opts.SecretToken == "" {
|
if autoSecret != "" {
|
||||||
bot.webhookLogger.Warnln("Using webhook without secret is very dangerous. Anyone can simulate Telegram requests.")
|
bot.webhookLogger.Warnln("Using webhook without secret is very dangerous. Using random 32 bytes token:", autoSecret)
|
||||||
}
|
}
|
||||||
|
|
||||||
i, err := bot.api.GetWebhookInfoWithContext(runCtx)
|
i, err := bot.api.GetWebhookInfoWithContext(runCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+8
-8
@@ -350,20 +350,20 @@ func TestRunWebhookWithContextRejectsInvalidTLSFilesBeforeRemoteSetup(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunWebhookWithContextRequiresSecretWhenStatusPathEnabled(t *testing.T) {
|
func TestRunWebhookWithContextAutoGeneratesSecretWhenEmpty(t *testing.T) {
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
prefixes: []string{"/"},
|
prefixes: []string{"/"},
|
||||||
plugins: []Plugin[NoData]{{name: "demo"}},
|
plugins: []Plugin[NoData]{{name: "demo"}},
|
||||||
}
|
}
|
||||||
opts := NewBotWebhookOpts().
|
// No SecretToken, no URL — function should auto-generate the token
|
||||||
SetURL("https://bot.example.com").
|
// and then fail with ErrNoBotWebhookOptsURL before any network call.
|
||||||
SetUseStatusPath(true)
|
opts := NewBotWebhookOpts().SetUseStatusPath(true)
|
||||||
|
|
||||||
err := bot.RunWebhookWithContext(context.Background(), opts)
|
err := bot.RunWebhookWithContext(context.Background(), opts)
|
||||||
if err == nil {
|
if !errors.Is(err, ErrNoBotWebhookOptsURL) {
|
||||||
t.Fatal("expected status-path secret validation error, got nil")
|
t.Fatalf("expected ErrNoBotWebhookOptsURL after auto-generation, got: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "SecretToken required") {
|
if opts.SecretToken == "" {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatal("expected SecretToken to be auto-generated, got empty string")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ func NewAPI(opts *APIOpts) *API {
|
|||||||
"API", utils.GetLoggerLevel(),
|
"API", utils.GetLoggerLevel(),
|
||||||
opts.logFormat, opts.logFormatter,
|
opts.logFormat, opts.logFormatter,
|
||||||
)
|
)
|
||||||
|
logger.AddReplacer(opts.token, "<TOKEN>")
|
||||||
|
|
||||||
client := opts.client
|
client := opts.client
|
||||||
if client == nil {
|
if client == nil {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ func NewUploader(api *API) *Uploader {
|
|||||||
"UPLOADER", utils.GetLoggerLevel(),
|
"UPLOADER", utils.GetLoggerLevel(),
|
||||||
api.logFormat, api.logFormatter,
|
api.logFormat, api.logFormatter,
|
||||||
)
|
)
|
||||||
|
logger.AddReplacer(api.token, "<TOKEN>")
|
||||||
return &Uploader{api, logger}
|
return &Uploader{api, logger}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +144,7 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
|
|||||||
req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString))
|
req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString))
|
||||||
req.ContentLength = int64(buf.Len())
|
req.ContentLength = int64(buf.Len())
|
||||||
|
|
||||||
up.logger.Debugln("UPLOADER REQ", r.method)
|
up.logger.Debugln("UPLOADER REQ", url)
|
||||||
resp, err := up.api.client.Do(req)
|
resp, err := up.api.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return zero, err
|
return zero, err
|
||||||
@@ -154,7 +155,7 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return zero, err
|
return zero, err
|
||||||
}
|
}
|
||||||
up.logger.Debugln("UPLOADER RES", r.method, string(body))
|
up.logger.Debugln("UPLOADER RES", url, string(body))
|
||||||
|
|
||||||
response, err := parseBody[R](body)
|
response, err := parseBody[R](body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package laniakea
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,3 +30,11 @@ const (
|
|||||||
// VersionBeta re-exports the module prerelease counter.
|
// VersionBeta re-exports the module prerelease counter.
|
||||||
VersionBeta = utils.VersionBeta
|
VersionBeta = utils.VersionBeta
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func generateToken(b int) (string, error) {
|
||||||
|
bytes := make([]byte, b)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.URLEncoding.EncodeToString(bytes), nil
|
||||||
|
}
|
||||||
|
|||||||
+2
-2
@@ -2,13 +2,13 @@ package utils
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// VersionString is the module version string.
|
// VersionString is the module version string.
|
||||||
VersionString = "1.0.0"
|
VersionString = "1.0.2"
|
||||||
// VersionMajor is the module major version.
|
// VersionMajor is the module major version.
|
||||||
VersionMajor = 1
|
VersionMajor = 1
|
||||||
// VersionMinor is the module minor version.
|
// VersionMinor is the module minor version.
|
||||||
VersionMinor = 0
|
VersionMinor = 0
|
||||||
// VersionPatch is the module patch version.
|
// VersionPatch is the module patch version.
|
||||||
VersionPatch = 0
|
VersionPatch = 2
|
||||||
// VersionBeta is the prerelease counter for the current version.
|
// VersionBeta is the prerelease counter for the current version.
|
||||||
VersionBeta = 0
|
VersionBeta = 0
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user