diff --git a/.gitignore b/.gitignore index 89ab47d..4c1fcdb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ test/ .codex .agents/ .claude/ +review.md \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml index 7ea3993..687b4d0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -2,7 +2,7 @@ version: "2" run: timeout: 5m linters: - disable-all: true + default: none enable: - errcheck - ineffassign diff --git a/AGENTS.md b/AGENTS.md index e57a7cd..f908ac0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,11 @@ Each exported godoc comment must: - avoid repeating the signature mechanically; - stay high-signal and informative. +### Telegram API documentation and versions +- When writing or updating godoc for Telegram Bot API types, fields, methods, or helpers, verify the description against the official [Telegram Bot API documentation](https://core.telegram.org/bots/api). Preserve relevant API semantics such as HTML equivalents, accepted ranges, formats, and optionality. +- Add a `Since: Bot API X.Y` paragraph to each exported type, function, and method introduced in a specific Bot API version, using the established `tgapi` format. +- Add an inline `// Since: Bot API X.Y` comment to an exported struct field only when its Bot API version differs from that of the containing struct. For example, if `InputRichMessage` was introduced in Bot API 10.1 and its `Media` field in Bot API 10.2, annotate only the `Media` field; do not repeat the struct's version on its original fields. + ### Unexported declarations Unexported types, funcs, methods, vars, and consts should generally not have godoc-style comments unless there is a strong reason. @@ -115,8 +120,8 @@ Prefer the repository’s documented commands. If multiple choices exist, use th ## Breaking changes policy - The agent must detect potential breaking changes before editing public APIs. -- Breaking changes are forbidden unless the selected target version is a new major version. -- If the requested change is breaking and the user did not bump the major version, the agent must stop and warn that the change is not allowed under the current version. +- Breaking changes are forbidden unless the selected target version is a new major version, or it's necessary(i.e. fixing not working feature). +- If the requested change is breaking, not necessary to fix a non-working feature, and the user did not bump the major version, the agent must stop and warn that the change is not allowed under the current version. - In that case, the agent must offer only these options: 1. do not make the breaking change; 2. introduce a backward-compatible alternative such as a new method, function, type, or struct, but only if that keeps the codebase reasonably small and clear; diff --git a/CHANGELOG.md b/CHANGELOG.md index 0576044..3c427d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,11 @@ ## v1.1.0 +### Breaking Changes +- Fixed `Uploader.SendLivePhoto` and `Uploader.SendLivePhotoWithContext` to require both the live-photo video and its static image. The previous one-file signatures could not produce a valid `sendLivePhoto` request. + ### Bot API 10.1 -- Added rich message receiving support: `tgapi.RichMessage` on `Message.RichMessage` (`rich_message`), the full set of `RichText*`/`RichBlock*` wire types with official API names, and `UnmarshalRichText`/`UnmarshalRichBlock`/`UnmarshalRichMessage` parsers with forward-compatible handling of unknown types. +- Added rich message receiving support: `tgapi.RichMessage` on `Message.RichMessage` (`rich_message`), the full set of `RichText*`/`RichBlock*` wire types with official API names, and `UnmarshalRichText`/`UnmarshalRichBlock`/`UnmarshalRichMessage` parsers. Unknown text-bearing types retain their nested text through fallback wrappers while unmodeled fields are discarded. - Added rich message sending support: `tgapi.InputRichMessage`, `tgapi.SendRichMessage` params, and `API.SendRichMessage`/`API.SendRichMessageWithContext`. - Added rich message draft streaming: `API.SendRichMessageDraft`/`API.SendRichMessageDraftWithContext` for ephemeral ~30-second previews of partially generated messages. - Added rich message editing: `EditMessageText.RichMessage` (`InputRichMessage`); `Text` is now omitted from the request when empty so rich-only edits are valid. @@ -11,9 +14,53 @@ - Added join request query support: `User.SupportsJoinRequestQueries`, `ChatFullInfo.GuardBot`, `ChatJoinRequest.QueryID`, `API.AnswerChatJoinRequestQuery` with `ChatJoinRequestQueryResult` constants (`JoinRequestApprove`/`JoinRequestDecline`/`JoinRequestQueue`), and `API.SendChatJoinRequestWebApp` (plus `WithContext` variants). - Added poll link media: the `tgapi.Link` type, `PollMedia.Link`, and the "link" type with `URL` on `InputPollOptionMedia`. +### Bot API 10.2 +- Added block-based rich-message sending with `InputRichMessage.Blocks`, including animation, audio, photo, video, and voice-note input blocks. The `tgrich` package provides matching media constructors with optional block captions. +- Added `InputRichMessage.Media` for media embedded in rich-message HTML or Markdown, with multipart `attach://` upload support. +- Added multipart rich-message uploads through `Uploader.SendRichMessage`. Use `UploaderFile.SetAttachName` to match an `attach://` media reference; rich-message draft helpers reject direct uploads as required by Telegram. +- Added ephemeral-message support: outgoing receiver and callback parameters, reply targets, message fields, edit and delete methods, and ephemeral bot commands. Added community service-message types and subscription update handling. + ### Added -- Added the `tgfmt` rich HTML DSL: typed `Rich` (inline) and `RichBlock` (block) fragments whose constructor signatures make invalid nesting uncompilable, inline helpers (`NewRich`, `Bold`, `Link`, `Mention`, `Emoji`, `Time`, `Math`, ...), block constructors (`H1`–`H6`, `P`, `Pre`/`PreCode`, `Footer`, `Hr`, `Ul`/`Ol`/`Li`/`LiCheckbox`, `Blockquote`/`Aside`, `Photo`/`Video`/`Audio` media with captions and spoilers, `Map`, `Collage`/`Slideshow`, `Table`/`Row`/`Cell`, `Details`, `MathBlock`, anchors), and the top-level `RichItem`/`RichHTML`/`RichMessage` assembly into `tgapi.InputRichMessage` (with `skip_entity_detection` enabled by default). -- Added `MessageContext.RichAnswer(...)` and `MessageContext.RichAnswerKeyboard(...)` for sending rich messages built from `tgfmt` fragments. +- Added documented `tgrich` constructors and block types for building input rich messages. +- Added `tgrich.BuildHTML` and `tgrich.ToHTML` to validate input block trees, convert them to HTML rich messages, and collect URL, `file_id`, or multipart media references. +- Added `MessageContext.RichAnswer(...)` and `MessageContext.RichAnswerKeyboard(...)` for validating and sending `tgrich` input blocks. +- Added `UpdateTypeSubscription` routing and normalized message, user, and chat context for guest messages, deleted business messages, anonymous poll answers, reaction counts, managed bots, chat boosts, and subscription updates. +- Added webhook secret-format validation and the exported `ErrBotWebhookOptsSecretTokenInvalid` sentinel. + +### Changed +- `AutoGenerateCommandsForScope(nil)` now atomically replaces commands in Telegram's default scope without deleting the previous list first. +- API debug logging now records redacted request JSON and response metadata instead of complete response bodies. +- Scene updates sharing a user or chat session key are serialized, and duplicate scene names from later plugins are skipped with a warning. +- Inline keyboard button builders now keep URL and callback actions mutually exclusive, and `InlineKeyboard.Get` returns independent markup data. +- `NewBot` no longer aliases `BotOpts.Prefixes` or mutates `BotOpts.LoggerBasePath`. +- README requirements now match the module's Go 1.26 directive. +- Migrated the golangci-lint configuration to its v2 schema so the repository lint workflow runs again. +- Added missing Godoc for exported error methods, enum constants, and all public Bot API 10.1/10.2 fields introduced in this release. + +### Fixed +- Fixed JSON BotOpts environment placeholders corrupting or injecting JSON when values contain quotes, backslashes, or control characters. +- Preserved checkbox state when converting list items to ordered lists and made generated HTML attribute ordering deterministic. +- Added validation for rich-block type discriminators, heading sizes, list fields, table cells, map parameters, and media values. +- Fixed generated webhook secrets using padded Base64 characters that Telegram rejects, stopped logging generated secrets, and added HTTP read and idle timeouts to the webhook server. +- Fixed negative group and channel IDs receiving global rather than per-chat `retry_after` cooldowns. +- Fixed rejected per-chat requests consuming global rate-limit capacity and draft construction consuming an extra rate-limit token before the API request. +- Fixed subscription updates being decoded as `UpdateTypeUnknown`. +- Fixed rich-text and rich-block decoding silently accepting malformed typed fields or a top-level `null` rich-text value. +- Fixed `tgrich.BuildHTML` disabling Telegram entity detection and accepting the draft-only thinking block; `BuildDraftHTML` now provides the explicit draft path. +- Fixed `tgrich.BuildHTML` silently losing explicit bank-card, mention, hashtag, cashtag, and bot-command values when their visible text differs. +- Fixed API debug logs exposing webhook, payment, callback, passport, and managed-bot secrets. +- Fixed panics and nil callbacks in asynchronous middleware terminating the process; failures now reach the logger and observer error stream. +- Fixed multipart helpers attempting direct file uploads for rich-message drafts, which Telegram does not support; they now return `ErrRichMessageDraftUploadUnsupported`. +- Fixed draft ID zero values and collisions overwriting tracked drafts, nil draft APIs panicking, and draft entity slices aliasing caller memory. +- Fixed scene session payloads and returned inline keyboard markup aliasing mutable internal slices. +- Recovered panics from runner callbacks so they are reported through normal runner and error observer events instead of terminating the process. + +### Tests +- Added JSON regression coverage for rich-message embedded media and ephemeral send, edit, and delete parameters. +- Added regression coverage for escaped environment placeholders and both required `sendLivePhoto` multipart fields. +- Added regression coverage for API log redaction, same-session scene serialization, duplicate scene registration, async middleware failures, and rich entity preservation. +- Added rich HTML renderer coverage for all input block and media types, multipart references, field validation, and Telegram's text, block, nesting, media, and table-width limits. +- Added regression coverage for webhook token syntax, update context normalization, subscription routing, rate-limit capacity, draft ID collisions, runner panics, scene and keyboard aliasing, command replacement, and malformed rich JSON. ## v1.0.2 diff --git a/README.md b/README.md index 286a26e..4be45d0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@  -[](https://go.dev/) +[](https://go.dev/) [](LICENSE)  diff --git a/README_RU.md b/README_RU.md index 1f91bc7..e7ff801 100644 --- a/README_RU.md +++ b/README_RU.md @@ -2,7 +2,7 @@  -[](https://go.dev/) +[](https://go.dev/) [](LICENSE)  diff --git a/bot.go b/bot.go index c1fd1e4..83dce9a 100644 --- a/bot.go +++ b/bot.go @@ -122,6 +122,7 @@ type Bot[T AppData] struct { sessionStore SessionStore // Session store for scene management sceneScopePriority []SceneScope + sceneLocks sceneKeyLocker updateOffsetMu sync.Mutex updateOffset int // Last processed update ID @@ -191,7 +192,7 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { api := tgapi.NewAPI(apiOpts) uploader := tgapi.NewUploader(api) - prefixes := opts.Prefixes + prefixes := append([]string(nil), opts.Prefixes...) if len(prefixes) == 0 { prefixes = []string{"/"} } @@ -230,10 +231,11 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { if len(opts.ErrorTemplate) > 0 { bot.errorTemplate = opts.ErrorTemplate } - if len(opts.LoggerBasePath) == 0 { - opts.LoggerBasePath = "./" + loggerOpts := *opts + if len(loggerOpts.LoggerBasePath) == 0 { + loggerOpts.LoggerBasePath = "./" } - bot.initLoggers(opts) + bot.initLoggers(&loggerOpts) if opts.FileConfigVersion > 0 && opts.FileConfigVersion < ConfigVersion { bot.logger.Warnln( diff --git a/bot_opts_loader.go b/bot_opts_loader.go index b3cf7cc..d8e50eb 100644 --- a/bot_opts_loader.go +++ b/bot_opts_loader.go @@ -127,8 +127,18 @@ func (codec BotOptsFileJSONCodec) Save(filename string, opts *BotOpts) error { return SaveBotOptsFile(codec, filename, opts) } +// EscapeEnv escapes an environment value for use inside a JSON string. +func (codec BotOptsFileJSONCodec) EscapeEnv(s string) string { + data, _ := json.Marshal(s) + return string(data[1 : len(data)-1]) +} + var envParameterRegex = regexp.MustCompile(`\{\{\s*(\w+)\s*\}\}`) +type botOptsFileEnvEscaper interface { + EscapeEnv(string) string +} + // BotOptsFileCodec decodes and encodes BotOpts file formats. type BotOptsFileCodec interface { FromBytes([]byte) (*BotOpts, error) @@ -148,7 +158,7 @@ func LoadBotOptsFile(codec BotOptsFileCodec, filename string) (*BotOpts, error) if err != nil { return nil, err } - data = expandEnvPlaceholdersInFile(data) + data = expandEnvPlaceholdersInFile(codec, data) return codec.FromBytes(data) } @@ -165,7 +175,7 @@ func SaveBotOptsFile(codec BotOptsFileCodec, filename string, opts *BotOpts) err return nil } -func expandEnvPlaceholdersInFile(data []byte) []byte { +func expandEnvPlaceholdersInFile(codec BotOptsFileCodec, data []byte) []byte { return envParameterRegex.ReplaceAllFunc(data, func(match []byte) []byte { group := envParameterRegex.FindSubmatch(match) if len(group) != 2 { @@ -173,6 +183,9 @@ func expandEnvPlaceholdersInFile(data []byte) []byte { } key := group[1] value := os.Getenv(string(key)) + if escaper, ok := codec.(botOptsFileEnvEscaper); ok { + value = escaper.EscapeEnv(value) + } return []byte(value) }) } diff --git a/bot_opts_loader_test.go b/bot_opts_loader_test.go index 7655366..2c78713 100644 --- a/bot_opts_loader_test.go +++ b/bot_opts_loader_test.go @@ -82,6 +82,25 @@ func TestLoadBotOptsFileExpandsEnvPlaceholders(t *testing.T) { } } +func TestLoadBotOptsFileEscapesEnvironmentValuesForJSON(t *testing.T) { + want := "quote: \"; slash: \\; newline:\n; tab:\t; control:\x01" + t.Setenv("TG_TOKEN", want) + + dir := t.TempDir() + filename := filepath.Join(dir, "config.json") + if err := os.WriteFile(filename, []byte(`{"token":"{{TG_TOKEN}}"}`), 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + got, err := LoadBotOptsFile(BotOptsFileJSONCodec{}, filename) + if err != nil { + t.Fatalf("LoadBotOptsFile returned error: %v", err) + } + if got.Token != want { + t.Fatalf("unexpected token: got %q want %q", got.Token, want) + } +} + func TestLoadBotOptsFileReturnsDecodeError(t *testing.T) { dir := t.TempDir() filename := filepath.Join(dir, "config.json") diff --git a/bot_register.go b/bot_register.go index 6f22aa8..be82bd8 100644 --- a/bot_register.go +++ b/bot_register.go @@ -19,6 +19,12 @@ func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { return bot } level := bot.GetLoggerLevel() + sceneOwners := make(map[string]string) + for _, registered := range bot.plugins { + for name := range registered.scenes { + sceneOwners[name] = registered.name + } + } for _, p := range plugin { if p == nil { if bot.logger != nil { @@ -27,6 +33,16 @@ func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { continue } cloned := clonePlugin(p) + for name := range cloned.scenes { + if owner, duplicate := sceneOwners[name]; duplicate { + if bot.logger != nil { + bot.logger.Warnf("scene %q from plugin %q duplicates plugin %q; skipping", name, cloned.name, owner) + } + delete(cloned.scenes, name) + continue + } + sceneOwners[name] = cloned.name + } if cloned.logger == nil { cloned.logger = utils.CreateLogger(cloned.name, level, bot.logFormat, bot.logFormatter) cloned.loggerOwned = true diff --git a/bot_webhook.go b/bot_webhook.go index 7e6a5d8..a92cbfe 100644 --- a/bot_webhook.go +++ b/bot_webhook.go @@ -152,6 +152,9 @@ func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOp if opts.MaxConnections > 100 || opts.MaxConnections <= 0 { return ErrBotWebhookOptsMaxConnectionsRange } + if err := validateWebhookSecretToken(opts.SecretToken); err != nil { + return err + } if err := validateWebhookPath(opts.Path, opts.UseStatusPath); err != nil { return err } @@ -165,7 +168,7 @@ func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOp return bot.runWebhookRuntime(ctx, func(runCtx context.Context) error { if autoSecret != "" { - bot.webhookLogger.Warnln("Using webhook without secret is very dangerous. Using random 32 bytes token:", autoSecret) + bot.webhookLogger.Warnln("No webhook secret was configured; generated a random secret token") } i, err := bot.api.GetWebhookInfoWithContext(runCtx) if err != nil { @@ -381,8 +384,11 @@ func (bot *Bot[T]) newWebhookMux(ctx context.Context, opts *BotWebhookOpts) *htt } func (bot *Bot[T]) baseRunWebhook(ctx context.Context, opts *BotWebhookOpts, runFunc func(*http.Server, chan error)) error { srv := &http.Server{ - Addr: fmt.Sprintf(":%d", opts.LocalPort), - Handler: bot.newWebhookMux(ctx, opts), + Addr: fmt.Sprintf(":%d", opts.LocalPort), + Handler: bot.newWebhookMux(ctx, opts), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, } errCh := make(chan error, 1) @@ -442,6 +448,21 @@ func validateWebhookPath(path string, useStatusPath bool) error { return nil } +func validateWebhookSecretToken(token string) error { + if len(token) < 1 || len(token) > 256 { + return ErrBotWebhookOptsSecretTokenInvalid + } + for _, r := range token { + if (r < 'A' || r > 'Z') && + (r < 'a' || r > 'z') && + (r < '0' || r > '9') && + r != '_' && r != '-' { + return ErrBotWebhookOptsSecretTokenInvalid + } + } + return nil +} + func validateWebhookTLSFiles(tlsFiles []string) error { switch len(tlsFiles) { case 0, 2: diff --git a/bot_webhook_test.go b/bot_webhook_test.go index 1789a25..9a5e1de 100644 --- a/bot_webhook_test.go +++ b/bot_webhook_test.go @@ -255,6 +255,33 @@ func TestValidateWebhookTLSFiles(t *testing.T) { } } +func TestValidateWebhookSecretToken(t *testing.T) { + tests := []struct { + name string + token string + wantErr bool + }{ + {name: "minimum", token: "a"}, + {name: "allowed alphabet", token: "AZaz09_-"}, + {name: "maximum", token: strings.Repeat("a", 256)}, + {name: "empty", token: "", wantErr: true}, + {name: "too long", token: strings.Repeat("a", 257), wantErr: true}, + {name: "padding", token: "abc=", wantErr: true}, + {name: "non ASCII", token: "секрет", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateWebhookSecretToken(tt.token) + if tt.wantErr && !errors.Is(err, ErrBotWebhookOptsSecretTokenInvalid) { + t.Fatalf("expected ErrBotWebhookOptsSecretTokenInvalid, got %v", err) + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + func TestUpdateHandlerRejectsOversizedBody(t *testing.T) { bot := &Bot[NoData]{ updateQueue: make(chan *tgapi.Update, 1), diff --git a/cmd_generator.go b/cmd_generator.go index 5cc3494..024976c 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -1,6 +1,7 @@ package laniakea import ( + "context" "errors" "fmt" "regexp" @@ -10,7 +11,6 @@ import ( "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) -// cmdRegexp matches command names allowed for Telegram command registration. var cmdRegexp = regexp.MustCompile("^[_a-z0-9]{1,32}$") // ErrTooManyCommands is returned when the total number of registered commands @@ -39,9 +39,9 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand { usage := fmt.Sprintf("Usage: /%s %s", cmd.command, strings.Join(descArgs, " ")) if desc != "" { desc = fmt.Sprintf("%s. %s", desc, usage) - return tgapi.BotCommand{Command: cmd.command, Description: desc} + return tgapi.BotCommand{Command: cmd.command, Description: desc, IsEphemeral: cmd.isEphemeral} } - return tgapi.BotCommand{Command: cmd.command, Description: usage} + return tgapi.BotCommand{Command: cmd.command, Description: usage, IsEphemeral: cmd.isEphemeral} } func checkCmdRegex(cmd string) bool { return cmdRegexp.MatchString(cmd) } @@ -79,15 +79,8 @@ func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand { return commands } -// AutoGenerateCommands registers all plugin-defined commands with Telegram's Bot API -// across three scopes: -// - Private chats (users) -// - Group chats -// - Group administrators -// -// It first deletes existing commands to ensure a clean state, then sets the new -// set of commands for all scopes. This ensures consistency even if commands were -// previously modified manually via @BotFather. +// AutoGenerateCommands replaces plugin-defined commands in the private-chat, +// group-chat, and all-chat-administrators scopes. // // Returns ErrTooManyCommands if the total number of commands exceeds 100. // Returns any API error from Telegram (e.g., network issues, invalid scope). @@ -102,40 +95,33 @@ func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand { // log.Fatal(err) // } func (bot *Bot[T]) AutoGenerateCommands() error { + return bot.AutoGenerateCommandsWithContext(context.Background()) +} + +// AutoGenerateCommandsWithContext is the context-aware variant of AutoGenerateCommands. +func (bot *Bot[T]) AutoGenerateCommandsWithContext(ctx context.Context) error { commands := gatherCommands(bot) if len(commands) > 100 { return ErrTooManyCommands } - // Clear existing commands to avoid duplication or stale entries - _, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommands{}) - if err != nil { - return fmt.Errorf("failed to delete existing commands: %w", err) - } - // Register commands for each scope - scopes := []*tgapi.BotCommandScope{ + scopes := []tgapi.BotCommandScope{ {Type: tgapi.BotCommandScopePrivateType}, {Type: tgapi.BotCommandScopeGroupType}, {Type: tgapi.BotCommandScopeAllChatAdministratorsType}, } - for _, scope := range scopes { - _, err = bot.api.SetMyCommands(tgapi.SetMyCommands{ - Commands: commands, - Scope: scope, - }) - if err != nil { - return fmt.Errorf("failed to set commands for scope %q: %w", scope.Type, err) + for i := range scopes { + if err := bot.setCommandsForScope(ctx, &scopes[i], commands); err != nil { + return err } } - return nil } // AutoGenerateCommandsForScope registers all plugin-defined commands with Telegram's Bot API -// for the specified command scope. It first deletes any existing commands in that scope -// to ensure a clean state, then sets the new set of commands. +// for the specified command scope. A nil scope selects Telegram's default scope. // // The scope parameter defines where the commands should be available (e.g., private chats, // group chats, chat administrators). See tgapi.BotCommandScope and its predefined types. @@ -150,22 +136,30 @@ func (bot *Bot[T]) AutoGenerateCommands() error { // log.Fatal(err) // } func (bot *Bot[T]) AutoGenerateCommandsForScope(scope *tgapi.BotCommandScope) error { + return bot.AutoGenerateCommandsForScopeWithContext(context.Background(), scope) +} + +// AutoGenerateCommandsForScopeWithContext is the context-aware variant of +// AutoGenerateCommandsForScope. +func (bot *Bot[T]) AutoGenerateCommandsForScopeWithContext(ctx context.Context, scope *tgapi.BotCommandScope) error { commands := gatherCommands(bot) if len(commands) > 100 { return ErrTooManyCommands } + return bot.setCommandsForScope(ctx, scope, commands) +} - _, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommands{Scope: scope}) - if err != nil { - return fmt.Errorf("failed to delete existing commands: %w", err) +func (bot *Bot[T]) setCommandsForScope(ctx context.Context, scope *tgapi.BotCommandScope, commands []tgapi.BotCommand) error { + if len(commands) > 100 { + return ErrTooManyCommands } - - _, err = bot.api.SetMyCommands(tgapi.SetMyCommands{ - Commands: commands, - Scope: scope, - }) + _, err := bot.api.SetMyCommandsWithContext(ctx, tgapi.SetMyCommands{Scope: scope, Commands: commands}) if err != nil { - return fmt.Errorf("failed to set commands for scope %q: %w", scope.Type, err) + scopeType := tgapi.BotCommandScopeDefaultType + if scope != nil { + scopeType = scope.Type + } + return fmt.Errorf("failed to set commands for scope %q: %w", scopeType, err) } return nil } diff --git a/cmd_generator_test.go b/cmd_generator_test.go index 8a4b6d6..e966246 100644 --- a/cmd_generator_test.go +++ b/cmd_generator_test.go @@ -83,3 +83,50 @@ func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) { t.Fatalf("unexpected command order: got %v want %v", got, want) } } + +func TestAutoGenerateCommandsForNilScopeUsesSingleAtomicReplacement(t *testing.T) { + var methods []string + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + methods = append(methods, req.URL.Path) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":true}`)), + }, nil + }), + } + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIURL("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + plugin := NewPlugin[NoData]("commands") + plugin.Command("start", func(ctx *MessageContext, db NoData) error { return nil }) + bot := &Bot[NoData]{ + api: api, + logger: sneklog.NewLogger(), + plugins: []Plugin[NoData]{*plugin}, + } + defer func() { + if err := bot.logger.Close(); err != nil { + t.Fatalf("Close logger returned error: %v", err) + } + }() + + if err := bot.AutoGenerateCommandsForScope(nil); err != nil { + t.Fatalf("AutoGenerateCommandsForScope returned error: %v", err) + } + if len(methods) != 1 { + t.Fatalf("expected one request, got %d", len(methods)) + } + if !strings.HasSuffix(methods[0], "/setMyCommands") { + t.Fatalf("expected setMyCommands request, got %v", methods[0]) + } +} diff --git a/commands.go b/commands.go index c333b3b..1dfc2c6 100644 --- a/commands.go +++ b/commands.go @@ -93,6 +93,7 @@ type Command[T AppData] struct { args extypes.Slice[CommandArg] // List of expected arguments middlewares extypes.Slice[Middleware[T]] // Optional middleware chain skipAutoCmd bool // If true, this command won't be auto-added to help menus + isEphemeral bool } // NewCommand creates a new Command with the given identifier, executor, and arguments. @@ -108,7 +109,9 @@ type Command[T AppData] struct { // that fit Telegram's callback_data limit, though the configured payload // encoding may impose its own restrictions. func NewCommand[T any](command string, exec CommandExecutor[T], args ...CommandArg) *Command[T] { - return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} + return &Command[T]{ + command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false, false, + } } // Use adds a middleware to the command's execution chain. @@ -130,6 +133,14 @@ func (c *Command[T]) SkipCommandAutoGen() *Command[T] { return c } +// SetEphemeral controls whether Telegram treats the command as ephemeral. +// +// Since: Bot API 10.2 +func (c *Command[T]) SetEphemeral(b bool) *Command[T] { + c.isEphemeral = b + return c +} + func (c *Command[T]) validateArgs(args []string) error { for i := range c.args.Len() { if i >= len(args) && c.args.Get(i).required { diff --git a/drafts.go b/drafts.go index af1fe57..159942a 100644 --- a/drafts.go +++ b/drafts.go @@ -129,7 +129,19 @@ type Draft struct { // // The caller must set a chat with SetChat before Push or Flush. func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft { - id := p.generator.Next() + p.mu.Lock() + defer p.mu.Unlock() + + var id uint64 + for { + id = p.generator.Next() + if id == 0 { + continue + } + if _, exists := p.drafts[id]; !exists { + break + } + } draft := &Draft{ api: p.api, provider: p, @@ -137,9 +149,7 @@ func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft { ID: id, Message: "", } - p.mu.Lock() p.drafts[id] = draft - p.mu.Unlock() return draft } @@ -154,10 +164,9 @@ func (d *Draft) SetChat(chatID int64, messageThreadID int) *Draft { // SetEntities replaces the draft's message entities. // -// Entities are stored by reference. If you plan to mutate the slice later, -// pass a copy: `SetEntities(append([]tgapi.MessageEntity{}, myEntities...))`. +// The entities slice is copied. func (d *Draft) SetEntities(entities []tgapi.MessageEntity) *Draft { - d.entities = entities + d.entities = append([]tgapi.MessageEntity(nil), entities...) return d } @@ -220,6 +229,9 @@ func (d *Draft) Flush() error { if err := validateMessageText(d.Message); err != nil { return err } + if d.api == nil { + return ErrAPIIsNil + } params := tgapi.SendMessage{ ChatID: d.chatID, @@ -252,6 +264,9 @@ func (d *Draft) push(text string) error { return err } d.Message = candidate + if d.api == nil { + return ErrAPIIsNil + } params := tgapi.SendMessageDraft{ ChatID: d.chatID, DraftID: d.ID, diff --git a/drafts_test.go b/drafts_test.go index 6c4d59a..4711474 100644 --- a/drafts_test.go +++ b/drafts_test.go @@ -9,6 +9,17 @@ import ( "git.scuroneko.dev/scuroneko/sneklog/v2" ) +type sequenceDraftIDGenerator struct { + ids []uint64 + pos int +} + +func (g *sequenceDraftIDGenerator) Next() uint64 { + id := g.ids[g.pos] + g.pos++ + return id +} + func TestDraftFlushRequiresChatID(t *testing.T) { draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone) draft.Message = "hello" @@ -38,6 +49,34 @@ func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) { } } +func TestDraftProviderSkipsZeroAndCollidingIDs(t *testing.T) { + provider := &DraftProvider{ + api: &tgapi.API{}, + drafts: make(map[uint64]*Draft), + generator: &sequenceDraftIDGenerator{ids: []uint64{0, 7, 7, 8}}, + } + + first := provider.NewDraft(tgapi.ParseNone) + second := provider.NewDraft(tgapi.ParseNone) + if first.ID != 7 || second.ID != 8 { + t.Fatalf("unexpected draft IDs: first=%d second=%d", first.ID, second.ID) + } + if got := len(provider.drafts); got != 2 { + t.Fatalf("collision overwrote a draft: got %d drafts", got) + } +} + +func TestDraftReturnsErrorWhenAPIIsNil(t *testing.T) { + draft := NewLinearDraftProvider(nil, 0).NewDraft(tgapi.ParseNone).SetChat(42, 0) + if err := draft.Push("hello"); !errors.Is(err, ErrAPIIsNil) { + t.Fatalf("expected ErrAPIIsNil from Push, got %v", err) + } + draft.Message = "hello" + if err := draft.Flush(); !errors.Is(err, ErrAPIIsNil) { + t.Fatalf("expected ErrAPIIsNil from Flush, got %v", err) + } +} + func TestDraftFlushRejectsLongMessage(t *testing.T) { draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone).SetChat(42, 0) draft.Message = strings.Repeat("a", maxMessageTextLen+1) diff --git a/error_model.go b/error_model.go index 940ba67..60b5590 100644 --- a/error_model.go +++ b/error_model.go @@ -8,6 +8,7 @@ type classifiedError struct { internalOnly bool } +// Error returns the underlying error message. func (e *classifiedError) Error() string { if e == nil || e.err == nil { return "" @@ -15,6 +16,7 @@ func (e *classifiedError) Error() string { return e.err.Error() } +// Unwrap returns the underlying error. func (e *classifiedError) Unwrap() error { if e == nil { return nil diff --git a/errors.go b/errors.go index 8077c7f..d430d46 100644 --- a/errors.go +++ b/errors.go @@ -69,6 +69,8 @@ var ( ErrNoBotWebhookOptsURL = errors.New("empty BotWebhookOpts.URL") // ErrBotWebhookOptsMaxConnectionsRange reports that BotWebhookOpts.MaxConnections is out of range. ErrBotWebhookOptsMaxConnectionsRange = errors.New("BotWebhookOpts.MaxConnections must be between 1 and 100") + // ErrBotWebhookOptsSecretTokenInvalid reports that SecretToken violates Telegram's format. + ErrBotWebhookOptsSecretTokenInvalid = errors.New("BotWebhookOpts.SecretToken must be 1-256 characters from A-Z, a-z, 0-9, _ and -") // ErrBotUploaderWhenCertificate reports that a certificate was set without an uploader. ErrBotUploaderWhenCertificate = errors.New("bot uploader nil, but certificate set") // ErrStatusPathSecretRequired reports that UseStatusPath requires SecretToken to be set. diff --git a/handler.go b/handler.go index eefee82..1b932e2 100644 --- a/handler.go +++ b/handler.go @@ -56,6 +56,8 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { ctx: ctx, } bot.prepareUpdateCtx(u, msgCtx) + unlockScenes := bot.sceneLocks.lock(sceneKeysForContext(msgCtx)) + defer unlockScenes() bot.safeEmitEvent(ctx, UpdateReceivedEvent{ UpdateID: u.UpdateID, UpdateType: u.Type, diff --git a/handler_test.go b/handler_test.go index f754741..60a79cb 100644 --- a/handler_test.go +++ b/handler_test.go @@ -318,6 +318,15 @@ func TestPrepareUpdateCtxContract(t *testing.T) { wantFrom: true, wantFromID: 115, }, + { + name: "anonymous poll answer", + update: &tgapi.Update{ + Type: tgapi.UpdateTypePollAnswer, + PollAnswer: &tgapi.PollAnswer{VoterChat: tgapi.Chat{ID: -2007}}, + }, + wantChat: true, + wantChatID: -2007, + }, { name: "message reaction", update: &tgapi.Update{ @@ -368,8 +377,64 @@ func TestPrepareUpdateCtxContract(t *testing.T) { name: "message reaction count", update: &tgapi.Update{ Type: tgapi.UpdateTypeMessageReactionCount, - MessageReactionCount: &tgapi.MessageReactionCountUpdated{}, + MessageReactionCount: &tgapi.MessageReactionCountUpdated{Chat: &tgapi.Chat{ID: -2008}}, }, + wantChat: true, + wantChatID: -2008, + }, + { + name: "guest message", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeGuestMessage, + GuestMessage: &tgapi.Message{ + From: &tgapi.User{ID: 119}, + Chat: &tgapi.Chat{ID: -2009}, + }, + }, + wantMsg: true, + wantFrom: true, + wantFromID: 119, + wantChat: true, + wantChatID: -2009, + }, + { + name: "deleted business messages", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeDeletedBusinessMessages, + DeletedBusinessMessages: &tgapi.BusinessMessagesDeleted{Chat: tgapi.Chat{ID: -2010}}, + }, + wantChat: true, + wantChatID: -2010, + }, + { + name: "managed bot", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeManagedBot, + ManagedBot: &tgapi.ManagedBotUpdated{User: tgapi.User{ID: 120}}, + }, + wantFrom: true, + wantFromID: 120, + }, + { + name: "subscription", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeSubscription, + Subscription: &tgapi.BotSubscriptionUpdated{User: tgapi.User{ID: 121}}, + }, + wantFrom: true, + wantFromID: 121, + }, + { + name: "giveaway chat boost has no user", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeChatBoost, + ChatBoost: &tgapi.ChatBoostUpdated{ + Chat: tgapi.Chat{ID: -2011}, + Boost: tgapi.ChatBoost{Source: tgapi.ChatBoostSource{Source: "giveaway"}}, + }, + }, + wantChat: true, + wantChatID: -2011, }, } diff --git a/keyboard.go b/keyboard.go index 58e9d2d..5ee0ae0 100644 --- a/keyboard.go +++ b/keyboard.go @@ -59,9 +59,12 @@ func (b InlineKeyboardButtonBuilder) SetStyle(style tgapi.KeyboardButtonStyle) I } // SetURL sets a URL that will be opened when the button is pressed. -// If both URL and CallbackData are set, Telegram will prioritize URL. +// It clears callback data because Telegram requires exactly one button action. func (b InlineKeyboardButtonBuilder) SetURL(url string) InlineKeyboardButtonBuilder { b.url = url + if url != "" { + b.data = "" + } return b } @@ -79,26 +82,29 @@ func (b InlineKeyboardButtonBuilder) SetPayloadType(t BotPayloadType) InlineKeyb // // Example: SetCallbackDataJSON("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]}. func (b InlineKeyboardButtonBuilder) SetCallbackDataJSON(cmd string, args ...any) InlineKeyboardButtonBuilder { + b.url = "" b.data = NewCallbackData(cmd, args...).ToJSON() return b } -// SetCallbackDataBase64 sets a structured callback payload encoded as Base64. -// This can be useful when the JSON payload exceeds Telegram's callback data length limit. -// Args are converted to strings using fmt.Sprint. +// SetCallbackDataBase64 sets a Base64-encoded structured callback payload. +// Base64 does not bypass Telegram's 64-byte callback-data limit. func (b InlineKeyboardButtonBuilder) SetCallbackDataBase64(cmd string, args ...any) InlineKeyboardButtonBuilder { + b.url = "" b.data = NewCallbackData(cmd, args...).ToBase64() return b } // SetCallbackDataCompact sets a structured callback payload encoded as compact text. func (b InlineKeyboardButtonBuilder) SetCallbackDataCompact(cmd string, args ...any) InlineKeyboardButtonBuilder { + b.url = "" b.data = NewCallbackData(cmd, args...).ToCompact() return b } // SetCallbackDataCompactBase64 sets a compact callback payload encoded as Base64. func (b InlineKeyboardButtonBuilder) SetCallbackDataCompactBase64(cmd string, args ...any) InlineKeyboardButtonBuilder { + b.url = "" b.data = NewCallbackData(cmd, args...).ToCompactBase64() return b } @@ -106,6 +112,7 @@ func (b InlineKeyboardButtonBuilder) SetCallbackDataCompactBase64(cmd string, ar // SetCallbackData sets a structured callback payload using the configured payload type. // The default payload type is JSON. func (b InlineKeyboardButtonBuilder) SetCallbackData(cmd string, args ...any) InlineKeyboardButtonBuilder { + b.url = "" switch b.payloadType { case BotPayloadJSON: b.data = NewCallbackData(cmd, args...).ToJSON() @@ -269,7 +276,11 @@ func (in *InlineKeyboard) Get() *tgapi.ReplyMarkup { if in.CurrentLine.Len() > 0 { in.AddLine() } - return &tgapi.ReplyMarkup{InlineKeyboard: in.Lines} + lines := make([][]tgapi.InlineKeyboardButton, len(in.Lines)) + for i := range in.Lines { + lines[i] = append([]tgapi.InlineKeyboardButton(nil), in.Lines[i]...) + } + return &tgapi.ReplyMarkup{InlineKeyboard: lines} } // CallbackData represents the structured payload sent when an inline button @@ -297,10 +308,7 @@ func NewCallbackData(command string, args ...any) CallbackData { for i, arg := range args { stringArgs[i] = fmt.Sprint(arg) } - return CallbackData{ - Command: command, - Args: stringArgs, - } + return CallbackData{Command: command, Args: stringArgs} } // All To* encoders return an empty string when serialization fails. Telegram diff --git a/keyboard_test.go b/keyboard_test.go index ecb1034..1c508aa 100644 --- a/keyboard_test.go +++ b/keyboard_test.go @@ -73,6 +73,35 @@ func TestInlineKeyboardButtonBuilderSetCallbackDataUsesConfiguredPayloadType(t * } } +func TestInlineKeyboardButtonBuilderKeepsExactlyOneAction(t *testing.T) { + callback := NewInlineKeyboardButton("Action"). + SetURL("https://example.test"). + SetCallbackDataJSON("confirm"). + build() + if callback.URL != "" || callback.CallbackData == "" { + t.Fatalf("callback action was not exclusive: %#v", callback) + } + + link := NewInlineKeyboardButton("Action"). + SetCallbackDataJSON("confirm"). + SetURL("https://example.test"). + build() + if link.URL == "" || link.CallbackData != "" { + t.Fatalf("URL action was not exclusive: %#v", link) + } +} + +func TestInlineKeyboardGetReturnsIndependentMarkup(t *testing.T) { + keyboard := NewInlineKeyboardJSON(1).AddURLButton("Docs", "https://example.test") + first := keyboard.Get() + first.InlineKeyboard[0][0].Text = "mutated" + + second := keyboard.Get() + if got := second.InlineKeyboard[0][0].Text; got != "Docs" { + t.Fatalf("Get exposed builder state for mutation: got %q", got) + } +} + func TestInlineKeyboardGetPayloadTypeReturnsLocalOverride(t *testing.T) { kb := NewInlineKeyboardJSON(2) if got := kb.GetPayloadType(); got != BotPayloadJSON { diff --git a/l10n.go b/l10n.go index 77c5092..c1c7a93 100644 --- a/l10n.go +++ b/l10n.go @@ -1,6 +1,9 @@ package laniakea -import "sync" +import ( + "maps" + "sync" +) // DictEntry maps language codes to translated strings. type DictEntry map[string]string @@ -64,8 +67,6 @@ func cloneDictEntry(src DictEntry) DictEntry { return nil } cloned := make(DictEntry, len(src)) - for lang, text := range src { - cloned[lang] = text - } + maps.Copy(cloned, src) return cloned } diff --git a/msg_context.go b/msg_context.go index e3c649b..68e41b1 100644 --- a/msg_context.go +++ b/msg_context.go @@ -8,10 +8,9 @@ import ( "reflect" "strconv" "strings" - "time" "git.scuroneko.dev/scuroneko/laniakea/tgapi" - "git.scuroneko.dev/scuroneko/laniakea/tgfmt" + "git.scuroneko.dev/scuroneko/laniakea/tgrich" "git.scuroneko.dev/scuroneko/sneklog/v2" ) @@ -551,28 +550,19 @@ func (ctx *MessageContext) newDraft(parseMode tgapi.ParseMode) *Draft { return nil } - if ctx.API.Limiter != nil { - c, cancel := context.WithTimeout(ctx.Context(), 5*time.Second) - defer cancel() - if err := ctx.API.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil { - ctx.Logger.Errorln(err) - return nil - } - } - draft := ctx.draftProvider.NewDraft(parseMode).SetChat(ctx.Msg.Chat.ID, ctx.Msg.MessageThreadID) return draft } // NewDraft creates a new message draft associated with the current chat. -// Uses the API limiter to avoid rate limiting. +// Draft sends are rate-limited by the API client. func (ctx *MessageContext) NewDraft() *Draft { return ctx.newDraft(tgapi.ParseNone) } // NewDraftMarkdown creates a new message draft associated with the current chat, // with Markdown V2 parse mode enabled. -// Uses the API limiter to avoid rate limiting. +// Draft sends are rate-limited by the API client. func (ctx *MessageContext) NewDraftMarkdown() *Draft { return ctx.newDraft(tgapi.ParseMarkdownV2) } @@ -852,14 +842,25 @@ func (ctx *MessageContext) richAnswer(rich tgapi.InputRichMessage, keyboard *Inl } } -// RichAnswer sends a rich message (Bot API 10.1) built from tgfmt fragments. -// Both inline (tgfmt.Rich) and block (tgfmt.RichBlock) fragments are accepted -// at the top level: Telegram merges adjacent inline content into paragraphs. -func (ctx *MessageContext) RichAnswer(items ...tgfmt.RichItem) *AnswerMessage { - return ctx.richAnswer(tgfmt.RichMessage(items...), nil) +func (ctx *MessageContext) richBlocksAnswer(keyboard *InlineKeyboard, blocks ...tgapi.InputRichBlock) *AnswerMessage { + rich, err := tgrich.BuildHTML(blocks...) + if err != nil { + ctx.Logger.Errorln(err) + return nil + } + return ctx.richAnswer(rich, keyboard) } -// RichAnswerKeyboard sends a rich message with an inline keyboard. -func (ctx *MessageContext) RichAnswerKeyboard(keyboard *InlineKeyboard, items ...tgfmt.RichItem) *AnswerMessage { - return ctx.richAnswer(tgfmt.RichMessage(items...), keyboard) +// RichAnswer builds and sends input rich-message blocks. +// +// Since: Bot API 10.2 +func (ctx *MessageContext) RichAnswer(blocks ...tgapi.InputRichBlock) *AnswerMessage { + return ctx.richBlocksAnswer(nil, blocks...) +} + +// RichAnswerKeyboard builds and sends input rich-message blocks with an inline keyboard. +// +// Since: Bot API 10.2 +func (ctx *MessageContext) RichAnswerKeyboard(keyboard *InlineKeyboard, blocks ...tgapi.InputRichBlock) *AnswerMessage { + return ctx.richBlocksAnswer(keyboard, blocks...) } diff --git a/msg_context_test.go b/msg_context_test.go index 6da8a93..e76f53d 100644 --- a/msg_context_test.go +++ b/msg_context_test.go @@ -10,9 +10,65 @@ import ( "testing" "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/laniakea/tgrich" "git.scuroneko.dev/scuroneko/sneklog/v2" ) +func TestRichAnswerBuildsInputBlocks(t *testing.T) { + var gotBody map[string]any + client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(body, &gotBody); err != nil { + t.Fatal(err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"message_id":9,"date":1}}`)), + }, nil + })} + api := tgapi.NewAPI(tgapi.NewAPIOpts("token").SetAPIURL("https://example.test").SetHTTPClient(client)) + defer func() { _ = api.Close() }() + ctx := &MessageContext{ + API: api, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, + Logger: sneklog.NewLogger(), + } + + answer := ctx.RichAnswer(tgrich.P(tgrich.Bold(tgrich.Text("ready")))) + if answer == nil { + t.Fatal("RichAnswer() returned nil") + } + rich, ok := gotBody["rich_message"].(map[string]any) + if !ok || rich["html"] != "
ready
" { + t.Fatalf("rich_message = %#v", gotBody["rich_message"]) + } + if _, exists := rich["skip_entity_detection"]; exists { + t.Fatalf("rich_message unexpectedly disables entity detection: %#v", rich) + } +} + +func TestRichAnswerRejectsInvalidBlocksWithoutRequest(t *testing.T) { + client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + t.Fatal("unexpected HTTP request") + return nil, nil + })} + api := tgapi.NewAPI(tgapi.NewAPIOpts("token").SetAPIURL("https://example.test").SetHTTPClient(client)) + defer func() { _ = api.Close() }() + ctx := &MessageContext{ + API: api, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, + Logger: sneklog.NewLogger(), + } + + if answer := ctx.RichAnswer(tgrich.H(tgrich.Text("invalid"), 0)); answer != nil { + t.Fatal("RichAnswer() returned an answer for an invalid heading") + } +} + func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { var gotBody map[string]any diff --git a/observer.go b/observer.go index 4ea52e1..eb37bc7 100644 --- a/observer.go +++ b/observer.go @@ -16,6 +16,8 @@ const ( HandlerCommandKind HandlerEventKind = "command" // HandlerMessageKind identifies a message fallback handler. HandlerMessageKind HandlerEventKind = "message" + // HandlerMiddlewareKind identifies middleware execution. + HandlerMiddlewareKind HandlerEventKind = "middleware" // HandlerPayloadKind identifies a callback payload handler. HandlerPayloadKind HandlerEventKind = "payload" // HandlerUpdateKind identifies a generic update handler. @@ -41,6 +43,24 @@ type Event interface { isEvent() } +func emitContextError(ctx *MessageContext, event ErrorEvent) { + if ctx == nil { + return + } + if ctx.Logger != nil { + ctx.Logger.Errorln(event.Err) + } + if ctx.observer == nil { + return + } + defer func() { + if recovered := recover(); recovered != nil && ctx.Logger != nil { + ctx.Logger.Errorln(fmt.Sprintf("panic in observer: %v", recovered)) + } + }() + ctx.observer.OnError(ctx.Context(), event) +} + // UpdateReceivedEvent describes an update entering the bot runtime. type UpdateReceivedEvent struct { UpdateID int @@ -162,9 +182,15 @@ func (bot *Bot[T]) safeEmitEvent(ctx context.Context, event Event) { } defer func() { if r := recover(); r != nil { - bot.logger.Errorln(fmt.Sprintf("panic in observer: %v", r)) + if bot.logger != nil { + bot.logger.Errorln(fmt.Sprintf("panic in observer: %v", r)) + } } }() + bot.emitEvent(ctx, event) +} + +func (bot *Bot[T]) emitEvent(ctx context.Context, event Event) { switch e := event.(type) { case UpdateReceivedEvent: bot.observer.OnUpdateReceived(ctx, e) diff --git a/plugins.go b/plugins.go index 122a137..7f759d0 100644 --- a/plugins.go +++ b/plugins.go @@ -2,6 +2,7 @@ package laniakea import ( "errors" + "fmt" "git.scuroneko.dev/scuroneko/extypes" "git.scuroneko.dev/scuroneko/laniakea/tgapi" @@ -292,6 +293,9 @@ func (p *Plugin[T]) executeMiddlewares(ctx *MessageContext, db T) bool { // If async, return value is ignored. type MiddlewareExecutor[T AppData] func(ctx *MessageContext, db T) bool +// ErrMiddlewareExecutorNil reports an attempt to execute middleware without a callback. +var ErrMiddlewareExecutorNil = errors.New("middleware executor is nil") + // Middleware represents a reusable execution interceptor. // Can be synchronous (blocking) or asynchronous (non-blocking). type Middleware[T AppData] struct { @@ -306,7 +310,7 @@ func NewMiddleware[T AppData](name string, executor MiddlewareExecutor[T]) Middl return Middleware[T]{name, executor, 0, false} } -// SetOrder sets the execution order (currently ignored). +// SetOrder sets the bot-level middleware execution order. func (m Middleware[T]) SetOrder(order int) Middleware[T] { m.order = order return m @@ -330,12 +334,38 @@ func (m Middleware[T]) SetAsync(async bool) Middleware[T] { // must treat those fields as read-only — mutating them races the sync chain // that mutates the same context concurrently. func (m Middleware[T]) Execute(ctx *MessageContext, db T) bool { + if m.executor == nil { + reportMiddlewareError(ctx, m.name, ErrMiddlewareExecutorNil) + return false + } if m.async { ctxCopy := *ctx go func(ctx MessageContext) { + defer func() { + if recovered := recover(); recovered != nil { + reportMiddlewareError(&ctx, m.name, fmt.Errorf("middleware %q panicked: %v", m.name, recovered)) + } + }() m.executor(&ctx, db) }(ctxCopy) return true } return m.executor(ctx, db) } + +func reportMiddlewareError(ctx *MessageContext, name string, err error) { + event := ErrorEvent{ + Plugin: "bot", + HandlerKind: HandlerMiddlewareKind, + HandlerName: name, + Err: err, + UserFacing: false, + } + if ctx != nil { + event.UpdateID = ctx.Update.UpdateID + event.UpdateType = ctx.Update.Type + event.FromID = ctx.FromID + event.ChatID = ctx.ChatID + } + emitContextError(ctx, event) +} diff --git a/plugins_test.go b/plugins_test.go index 037b4a3..b02216f 100644 --- a/plugins_test.go +++ b/plugins_test.go @@ -1,10 +1,65 @@ package laniakea import ( + "context" "errors" "testing" + "time" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) +type middlewareErrorObserver struct { + testObserver + errors chan ErrorEvent +} + +func (o *middlewareErrorObserver) OnError(_ context.Context, event ErrorEvent) { + o.errors <- event +} + +func TestAsyncMiddlewareRecoversPanic(t *testing.T) { + observer := &middlewareErrorObserver{errors: make(chan ErrorEvent, 1)} + ctx := &MessageContext{ + Update: tgapi.Update{UpdateID: 7, Type: tgapi.UpdateTypeMessage}, + FromID: 42, + ChatID: 100, + observer: observer, + } + middleware := NewMiddleware[NoData]("panic", func(ctx *MessageContext, db NoData) bool { + panic("boom") + }).SetAsync(true) + + if !middleware.Execute(ctx, NoData{}) { + t.Fatal("async middleware blocked execution") + } + select { + case event := <-observer.errors: + if event.HandlerKind != HandlerMiddlewareKind || event.HandlerName != "panic" { + t.Fatalf("unexpected error event: %#v", event) + } + if event.Err == nil { + t.Fatal("panic error was not reported") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for async middleware error") + } +} + +func TestMiddlewareRejectsNilExecutor(t *testing.T) { + observer := &middlewareErrorObserver{errors: make(chan ErrorEvent, 1)} + ctx := &MessageContext{observer: observer} + middleware := NewMiddleware[NoData]("nil", nil) + + if middleware.Execute(ctx, NoData{}) { + t.Fatal("nil middleware executor was accepted") + } + event := <-observer.errors + if !errors.Is(event.Err, ErrMiddlewareExecutorNil) { + t.Fatalf("error = %v, want ErrMiddlewareExecutorNil", event.Err) + } +} + func TestValidateArgsRequiresFullMatch(t *testing.T) { intCmd := NewCommand("int", func(ctx *MessageContext, db NoData) error { return nil }, NewCommandArg("n").SetValueType(CommandValueInt).SetRequired()) if err := intCmd.validateArgs([]string{"123"}); err != nil { diff --git a/runners.go b/runners.go index f6ec4a1..91d2f5c 100644 --- a/runners.go +++ b/runners.go @@ -2,6 +2,7 @@ package laniakea import ( "context" + "fmt" "time" ) @@ -27,6 +28,18 @@ type Runner[T AppData] struct { fn RunnerFn[T] // The function to execute } +func executeRunner[T AppData](runner Runner[T], bot *Bot[T]) (err error) { + defer func() { + if recovered := recover(); recovered != nil { + err = fmt.Errorf("runner %q panicked: %v", runner.name, recovered) + } + }() + if runner.fn == nil { + return fmt.Errorf("runner %q has no function", runner.name) + } + return runner.fn(bot) +} + // NewRunner creates a new Runner with the given name and function. // // The default configuration is async=true and every=0, i.e. a one-shot @@ -34,12 +47,7 @@ type Runner[T AppData] struct { // to customize this. Do not call builder methods concurrently or after the // bot runtime has begun executing runners. func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] { - return Runner[T]{ - name: name, - fn: fn, - async: true, - every: 0, - } + return Runner[T]{name: name, fn: fn, async: true, every: 0} } // Async sets whether the runner executes synchronously or asynchronously. @@ -89,7 +97,7 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { go func(r Runner[T]) { defer bot.runnerOnceWG.Done() startedAt := time.Now() - err := r.fn(bot) + err := executeRunner(r, bot) bot.safeEmitEvent(ctx, RunnerFinishedEvent{ Name: r.name, Duration: time.Since(startedAt), @@ -109,7 +117,7 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { } else if runner.every == 0 && !runner.async { // One-time sync: block until done t := time.Now() - err := runner.fn(bot) + err := executeRunner(runner, bot) elapsed := time.Since(t) bot.safeEmitEvent(ctx, RunnerFinishedEvent{ Name: runner.name, @@ -150,7 +158,7 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { return } startedAt := time.Now() - err := r.fn(bot) + err := executeRunner(r, bot) bot.safeEmitEvent(ctx, RunnerFinishedEvent{ Name: r.name, Duration: time.Since(startedAt), diff --git a/runners_test.go b/runners_test.go index 496df4d..fe423c8 100644 --- a/runners_test.go +++ b/runners_test.go @@ -95,3 +95,30 @@ func TestExecRunnersEmitObserverEvents(t *testing.T) { t.Fatalf("unexpected runner error event: %#v", got) } } + +func TestExecRunnersRecoversRunnerPanic(t *testing.T) { + observer := &runnerObserver{} + bot := &Bot[NoData]{ + logger: sneklog.NewLogger(), + observer: observer, + runners: []Runner[NoData]{ + NewRunner("panic", func(*Bot[NoData]) error { + panic("boom") + }).Async(false), + }, + } + defer func() { + if err := bot.logger.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + bot.ExecRunners(context.Background()) + + if len(observer.runners) != 1 || observer.runners[0].Err == nil { + t.Fatalf("expected recovered panic in runner event, got %#v", observer.runners) + } + if len(observer.errors) != 1 || observer.errors[0].Err == nil { + t.Fatalf("expected recovered panic in error event, got %#v", observer.errors) + } +} diff --git a/scene.go b/scene.go index 1bd17a4..baad8fd 100644 --- a/scene.go +++ b/scene.go @@ -1,6 +1,7 @@ package laniakea import ( + "bytes" "encoding/json" "maps" "sync" @@ -131,18 +132,17 @@ type SceneSession struct { Scene string // Step is the current step name inside the active scene. Step string - // data stores opaque session payload bytes, typically JSON. data []byte } // SetData stores arbitrary opaque session data. func (s *SceneSession) SetData(data []byte) { - s.data = data + s.data = bytes.Clone(data) } // GetData returns the raw session data payload. func (s *SceneSession) GetData() []byte { - return s.data + return bytes.Clone(s.data) } // HasData reports whether the session has a non-empty data payload. @@ -198,6 +198,7 @@ func (s *MemorySessionStore) Get(key string) (SceneSession, error) { s.mu.RLock() defer s.mu.RUnlock() if session, ok := s.store[key]; ok { + session.data = bytes.Clone(session.data) return session, nil } return SceneSession{}, nil @@ -205,6 +206,7 @@ func (s *MemorySessionStore) Get(key string) (SceneSession, error) { // Set stores session under key. func (s *MemorySessionStore) Set(key string, session SceneSession) error { + session.data = bytes.Clone(session.data) s.mu.Lock() s.store[key] = session s.mu.Unlock() diff --git a/scene_locks.go b/scene_locks.go new file mode 100644 index 0000000..5b01f0d --- /dev/null +++ b/scene_locks.go @@ -0,0 +1,80 @@ +package laniakea + +import ( + "sort" + "sync" +) + +type sceneLockEntry struct { + mu sync.Mutex + refs int +} + +type sceneKeyLocker struct { + mu sync.Mutex + entries map[string]*sceneLockEntry +} + +func (l *sceneKeyLocker) lock(keys []string) func() { + keys = uniqueSortedStrings(keys) + if len(keys) == 0 { + return func() {} + } + + l.mu.Lock() + if l.entries == nil { + l.entries = make(map[string]*sceneLockEntry) + } + entries := make([]*sceneLockEntry, len(keys)) + for i, key := range keys { + entry := l.entries[key] + if entry == nil { + entry = new(sceneLockEntry) + l.entries[key] = entry + } + entry.refs++ + entries[i] = entry + } + l.mu.Unlock() + + for _, entry := range entries { + entry.mu.Lock() + } + + return func() { + for i := len(entries) - 1; i >= 0; i-- { + entries[i].mu.Unlock() + } + + l.mu.Lock() + for i, key := range keys { + entries[i].refs-- + if entries[i].refs == 0 { + delete(l.entries, key) + } + } + l.mu.Unlock() + } +} + +func uniqueSortedStrings(values []string) []string { + sort.Strings(values) + result := values[:0] + for _, value := range values { + if value == "" || len(result) > 0 && result[len(result)-1] == value { + continue + } + result = append(result, value) + } + return result +} + +func sceneKeysForContext(ctx *MessageContext) []string { + keys := make([]string, 0, 3) + for _, scope := range []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser} { + if key, ok := buildSceneKey(scope, ctx); ok { + keys = append(keys, key) + } + } + return keys +} diff --git a/scene_test.go b/scene_test.go index db34f82..25e44da 100644 --- a/scene_test.go +++ b/scene_test.go @@ -3,7 +3,10 @@ package laniakea import ( "context" "errors" + "sync" + "sync/atomic" "testing" + "time" "git.scuroneko.dev/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/sneklog/v2" @@ -41,6 +44,114 @@ func TestPluginAddSceneRegistersScene(t *testing.T) { } } +func TestBotAddPluginsSkipsDuplicateSceneNames(t *testing.T) { + first := NewPlugin[NoData]("first") + first.Scene("shared") + second := NewPlugin[NoData]("second") + second.Scene("shared") + + bot := &Bot[NoData]{logger: sneklog.NewLogger()} + bot.AddPlugins(first, second) + + if _, ok := bot.plugins[0].scenes["shared"]; !ok { + t.Fatal("first registered scene was removed") + } + if _, ok := bot.plugins[1].scenes["shared"]; ok { + t.Fatal("duplicate scene was registered") + } +} + +func TestSceneUpdatesForSameSessionAreSerialized(t *testing.T) { + entered := make(chan int, 2) + releaseFirst := make(chan struct{}) + var calls atomic.Int64 + + plugin := NewPlugin[NoData]("wizard") + plugin.Scene("counter"). + SetEntry("start"). + OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { + var value int + if err := ctx.BindData(&value); err != nil { + return SceneResult{}, err + } + call := int(calls.Add(1)) + entered <- call + if call == 1 { + <-releaseFirst + } + value++ + if err := ctx.SaveData(value); err != nil { + return SceneResult{}, err + } + return ctx.Stay(), nil + }) + + bot := &Bot[NoData]{ + logger: sneklog.NewLogger(), + prefixes: []string{"/"}, + sessionStore: NewMemorySessionStore(), + sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, + } + bot.AddPlugins(plugin) + key := "user_id:42:chat_id:100" + session := SceneSession{Scene: "counter", Step: "start"} + if err := session.SaveData(0); err != nil { + t.Fatalf("SaveData returned error: %v", err) + } + if err := bot.sessionStore.Set(key, session); err != nil { + t.Fatalf("Set returned error: %v", err) + } + + update := func(id int) *tgapi.Update { + return &tgapi.Update{ + UpdateID: id, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: id, + Text: "increment", + Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}, + From: &tgapi.User{ID: 42}, + }, + } + } + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + bot.handle(context.Background(), update(1)) + }() + if got := <-entered; got != 1 { + t.Fatalf("first handler call = %d, want 1", got) + } + go func() { + defer wg.Done() + bot.handle(context.Background(), update(2)) + }() + select { + case call := <-entered: + t.Fatalf("second handler entered before first completed: call %d", call) + case <-time.After(50 * time.Millisecond): + } + close(releaseFirst) + if got := <-entered; got != 2 { + t.Fatalf("second handler call = %d, want 2", got) + } + wg.Wait() + + got, err := bot.sessionStore.Get(key) + if err != nil { + t.Fatalf("Get returned error: %v", err) + } + var value int + if err := got.BindData(&value); err != nil { + t.Fatalf("BindData returned error: %v", err) + } + if value != 2 { + t.Fatalf("session value = %d, want 2", value) + } +} + func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) { called := false @@ -836,6 +947,36 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { } } +func TestMemorySessionStoreDoesNotAliasSessionData(t *testing.T) { + store := NewMemorySessionStore() + input := []byte("initial") + session := SceneSession{Scene: "signup"} + session.SetData(input) + + input[0] = 'X' + if got := string(session.GetData()); got != "initial" { + t.Fatalf("SetData retained caller slice: got %q", got) + } + if err := store.Set("user:1", session); err != nil { + t.Fatalf("Set returned error: %v", err) + } + + first, err := store.Get("user:1") + if err != nil { + t.Fatalf("Get returned error: %v", err) + } + data := first.GetData() + data[0] = 'X' + + second, err := store.Get("user:1") + if err != nil { + t.Fatalf("second Get returned error: %v", err) + } + if got := string(second.GetData()); got != "initial" { + t.Fatalf("Get exposed stored data for mutation: got %q", got) + } +} + func TestFindSceneSessionSupportsUserScopeWithoutMessage(t *testing.T) { bot := &Bot[NoData]{ logger: sneklog.NewLogger(), diff --git a/tgapi/api.go b/tgapi/api.go index ae4e8f6..4261fc8 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -236,7 +236,7 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro req.Body = io.NopCloser(buf) req.ContentLength = int64(len(reqData)) - api.logger.Debugln("REQ", url, string(reqData)) + api.logger.Debugln("REQ", url, redactRequestLog(reqData)) resp, err := api.client.Do(req) if err != nil { return zero, fmt.Errorf("HTTP request failed: %w", err) @@ -248,7 +248,7 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro return zero, fmt.Errorf("failed to read response body: %w", err) } - api.logger.Debugln("RES", r.method, string(respData)) + api.logger.Debugln("RES", responseLogSummary(r.method, len(respData))) response, err := parseBody[R](respData) if err != nil { @@ -269,7 +269,7 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro // Apply cooldown to global or chat-specific limiter if api.Limiter != nil { - if r.chatID > 0 { + if r.chatID != 0 { api.Limiter.SetChatLock(r.chatID, after) } else { api.Limiter.SetGlobalLock(after) diff --git a/tgapi/api101_test.go b/tgapi/api101_test.go index d21549c..05b4ca9 100644 --- a/tgapi/api101_test.go +++ b/tgapi/api101_test.go @@ -64,6 +64,100 @@ func TestInputRichMessageContentMarshal(t *testing.T) { } } +func TestInputRichMessageMediaMarshal(t *testing.T) { + message := InputRichMessage{ + HTML: ``, + Media: []InputRichMessageMedia{{ + ID: "intro", + Media: InputMedia{Type: InputMediaTypeVideo, Media: "attach://intro"}, + }}, + } + data, err := json.Marshal(message) + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + + var got struct { + Media []struct { + ID string `json:"id"` + Media InputMedia `json:"media"` + } `json:"media"` + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal returned error: %v", err) + } + if len(got.Media) != 1 || got.Media[0].ID != "intro" { + t.Fatalf("unexpected media: %+v", got.Media) + } + if got.Media[0].Media.Type != InputMediaTypeVideo || got.Media[0].Media.Media != "attach://intro" { + t.Fatalf("unexpected embedded media: %+v", got.Media[0].Media) + } +} + +func TestEphemeralMethodsMarshalReceiverUserID(t *testing.T) { + cases := []struct { + name string + params any + }{ + {"edit text", EditEphemeralMessageText{ChatID: 1, ReceiverUserID: 2, EphemeralMessageID: 3, Text: "updated"}}, + {"edit media", EditEphemeralMessageMedia{ChatID: 1, ReceiverUserID: 2, EphemeralMessageID: 3, Media: InputMedia{Type: InputMediaTypePhoto, Media: "photo-id"}}}, + {"edit caption", EditEphemeralMessageCaption{ChatID: 1, ReceiverUserID: 2, EphemeralMessageID: 3, Caption: "updated"}}, + {"edit markup", EditEphemeralMessageReplyMarkup{ChatID: 1, ReceiverUserID: 2, EphemeralMessageID: 3}}, + {"delete", DeleteEphemeralMessage{ChatID: 1, ReceiverUserID: 2, EphemeralMessageID: 3}}, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(tt.params) + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatalf("Unmarshal returned error: %v", err) + } + if _, ok := fields["receiver_user_id"]; !ok { + t.Fatalf("receiver_user_id is missing from %s", data) + } + if _, ok := fields["reciever_user_id"]; ok { + t.Fatalf("misspelled receiver_user_id is present in %s", data) + } + }) + } +} + +func TestEphemeralSendParametersMarshal(t *testing.T) { + cases := []struct { + name string + params any + }{ + {"message", SendMessage{ChatID: 1, Text: "text", ReceiverUserID: 2, CallbackQueryID: "callback"}}, + {"animation", SendAnimation{ChatID: 1, Animation: "animation", ReceiverUserID: 2, CallbackQueryID: "callback"}}, + {"audio", SendAudio{ChatID: 1, Audio: "audio", ReceiverUserID: 2, CallbackQueryID: "callback"}}, + {"document", SendDocument{ChatID: 1, Document: "document", ReceiverUserID: 2, CallbackQueryID: "callback"}}, + {"photo", SendPhoto{ChatID: 1, Photo: "photo", ReceiverUserID: 2, CallbackQueryID: "callback"}}, + {"sticker", SendSticker{ChatID: 1, Sticker: "sticker", ReceiverUserID: 2, CallbackQueryID: "callback"}}, + {"video", SendVideo{ChatID: 1, Video: "video", ReceiverUserID: 2, CallbackQueryID: "callback"}}, + {"video note", SendVideoNote{ChatID: 1, VideoNote: "video-note", ReceiverUserID: 2, CallbackQueryID: "callback"}}, + {"voice", SendVoice{ChatID: 1, Voice: "voice", ReceiverUserID: 2, CallbackQueryID: "callback"}}, + {"contact", SendContact{ChatID: 1, PhoneNumber: "+10000000000", FirstName: "A", ReceiverUserID: 2, CallbackQueryID: "callback"}}, + {"location", SendLocation{ChatID: 1, Latitude: 1, Longitude: 2, ReceiverUserID: 2, CallbackQueryID: "callback"}}, + {"venue", SendVenue{ChatID: 1, Latitude: 1, Longitude: 2, Title: "Venue", Address: "Address", ReceiverUserID: 2, CallbackQueryID: "callback"}}, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(tt.params) + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + if !strings.Contains(string(data), `"receiver_user_id":2`) || !strings.Contains(string(data), `"callback_query_id":"callback"`) { + t.Fatalf("missing ephemeral parameters in %s", data) + } + }) + } +} + func TestInputPollOptionMediaLinkMarshal(t *testing.T) { media := InputPollOptionMedia{Type: "link", URL: "https://example.com"} data, err := json.Marshal(media) diff --git a/tgapi/attachments_methods.go b/tgapi/attachments_methods.go index dfe0ee2..504a116 100644 --- a/tgapi/attachments_methods.go +++ b/tgapi/attachments_methods.go @@ -10,6 +10,10 @@ type SendPhoto struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + // ReceiverUserID identifies the user who can see the ephemeral message. + ReceiverUserID int64 `json:"receiver_user_id,omitempty"` // Since: Bot API 10.2 + // CallbackQueryID identifies the callback query that triggered an ephemeral response. + CallbackQueryID string `json:"callback_query_id,omitempty"` // Since: Bot API 10.2 Photo string `json:"photo"` Caption string `json:"caption,omitempty"` @@ -53,6 +57,10 @@ type SendAudio struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + // ReceiverUserID identifies the user who can see the ephemeral message. + ReceiverUserID int64 `json:"receiver_user_id,omitempty"` // Since: Bot API 10.2 + // CallbackQueryID identifies the callback query that triggered an ephemeral response. + CallbackQueryID string `json:"callback_query_id,omitempty"` // Since: Bot API 10.2 Audio string `json:"audio"` Caption string `json:"caption,omitempty"` @@ -98,6 +106,10 @@ type SendDocument struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + // ReceiverUserID identifies the user who can see the ephemeral message. + ReceiverUserID int64 `json:"receiver_user_id,omitempty"` // Since: Bot API 10.2 + // CallbackQueryID identifies the callback query that triggered an ephemeral response. + CallbackQueryID string `json:"callback_query_id,omitempty"` // Since: Bot API 10.2 Document string `json:"document"` Thumbnail string `json:"thumbnail,omitempty"` @@ -141,6 +153,10 @@ type SendVideo struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + // ReceiverUserID identifies the user who can see the ephemeral message. + ReceiverUserID int64 `json:"receiver_user_id,omitempty"` // Since: Bot API 10.2 + // CallbackQueryID identifies the callback query that triggered an ephemeral response. + CallbackQueryID string `json:"callback_query_id,omitempty"` // Since: Bot API 10.2 Video string `json:"video"` Thumbnail string `json:"thumbnail,omitempty"` @@ -192,6 +208,10 @@ type SendAnimation struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + // ReceiverUserID identifies the user who can see the ephemeral message. + ReceiverUserID int64 `json:"receiver_user_id,omitempty"` // Since: Bot API 10.2 + // CallbackQueryID identifies the callback query that triggered an ephemeral response. + CallbackQueryID string `json:"callback_query_id,omitempty"` // Since: Bot API 10.2 Animation string `json:"animation"` Thumbnail string `json:"thumbnail,omitempty"` @@ -239,6 +259,10 @@ type SendVoice struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + // ReceiverUserID identifies the user who can see the ephemeral message. + ReceiverUserID int64 `json:"receiver_user_id,omitempty"` // Since: Bot API 10.2 + // CallbackQueryID identifies the callback query that triggered an ephemeral response. + CallbackQueryID string `json:"callback_query_id,omitempty"` // Since: Bot API 10.2 Voice string `json:"voice"` Caption string `json:"caption,omitempty"` @@ -280,6 +304,10 @@ type SendVideoNote struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + // ReceiverUserID identifies the user who can see the ephemeral message. + ReceiverUserID int64 `json:"receiver_user_id,omitempty"` // Since: Bot API 10.2 + // CallbackQueryID identifies the callback query that triggered an ephemeral response. + CallbackQueryID string `json:"callback_query_id,omitempty"` // Since: Bot API 10.2 VideoNote string `json:"video_note"` Thumbnail string `json:"thumbnail,omitempty"` @@ -397,7 +425,13 @@ type SendLivePhoto struct { MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` - LivePhoto string `json:"live_photo"` + // ReceiverUserID identifies the user who can see the ephemeral message. + ReceiverUserID int64 `json:"receiver_user_id,omitempty"` // Since: Bot API 10.2 + // CallbackQueryID identifies the callback query that triggered an ephemeral response. + CallbackQueryID string `json:"callback_query_id,omitempty"` // Since: Bot API 10.2 + LivePhoto string `json:"live_photo"` + // Photo contains or identifies the associated photo. + Photo string `json:"photo"` Caption string `json:"caption,omitempty"` ParseMode ParseMode `json:"parse_mode,omitempty"` CaptionEntities []MessageEntity `json:"caption_entities,omitempty"` diff --git a/tgapi/attachments_types.go b/tgapi/attachments_types.go index b6ac278..f8c42b6 100644 --- a/tgapi/attachments_types.go +++ b/tgapi/attachments_types.go @@ -112,9 +112,13 @@ type PaidMediaInfo struct { type PaidMediaType string const ( - PaidMediaPreviewType PaidMediaType = "preview" - PaidMediaPhotoType PaidMediaType = "photo" - PaidMediaVideoType PaidMediaType = "video" + // PaidMediaPreviewType identifies a paid-media preview. + PaidMediaPreviewType PaidMediaType = "preview" + // PaidMediaPhotoType identifies a paid photo. + PaidMediaPhotoType PaidMediaType = "photo" + // PaidMediaVideoType identifies a paid video. + PaidMediaVideoType PaidMediaType = "video" + // PaidMediaLivePhotoType identifies a paid live photo. PaidMediaLivePhotoType PaidMediaType = "live_photo" // Since: Bot API 10.0 ) @@ -172,7 +176,8 @@ type PollOption struct { type InputPollOptionMedia struct { Type string `json:"type"` Media string `json:"media,omitempty"` - URL string `json:"url,omitempty"` // Since: Bot API 10.1; for type "link" + // URL contains the HTTP URL. + URL string `json:"url,omitempty"` // Since: Bot API 10.1; for type "link" } // InputPollOption contains information about one answer option in a poll to be sent. @@ -226,8 +231,8 @@ const ( // See https://core.telegram.org/bots/api#pollanswer type PollAnswer struct { PollID string `json:"poll_id"` - VoterChat Chat `json:"voter_chat"` // Since: Bot API 6.8 - User User `json:"user"` + VoterChat Chat `json:"voter_chat,omitempty"` // Since: Bot API 6.8 + User User `json:"user,omitempty"` // FIXME: Pointer in v2 OptionIDs []int `json:"option_ids"` OptionPersistentIDs []string `json:"option_persistent_ids"` // Since: Bot API 9.6 } @@ -264,15 +269,17 @@ type Poll struct { // Since: Bot API 10.1 // See https://core.telegram.org/bots/api#link type Link struct { + // URL contains the HTTP URL. URL string `json:"url"` } // PollMedia represents media attached to a poll. // Since: Bot API 10.0 type PollMedia struct { - Animation *Animation `json:"animation,omitempty"` - Audio *Audio `json:"audio,omitempty"` - Document *Document `json:"document,omitempty"` + Animation *Animation `json:"animation,omitempty"` + Audio *Audio `json:"audio,omitempty"` + Document *Document `json:"document,omitempty"` + // Link contains link media attached to the poll. Link *Link `json:"link,omitempty"` // Since: Bot API 10.1 LivePhoto *LivePhoto `json:"live_photo,omitempty"` Location *Location `json:"location,omitempty"` @@ -352,10 +359,18 @@ const ( InputMediaTypeVideo InputMediaType = "video" // InputMediaTypeAudio is an audio file. InputMediaTypeAudio InputMediaType = "audio" + // InputMediaTypeVoiceNote is a voice message. + // + // Since: Bot API 10.2 + InputMediaTypeVoiceNote InputMediaType = "voice_note" - InputMediaTypeSticker InputMediaType = "sticker" - InputMediaTypeLocation InputMediaType = "location" - InputMediaTypeVenue InputMediaType = "venue" + // InputMediaTypeSticker is a sticker. + InputMediaTypeSticker InputMediaType = "sticker" + // InputMediaTypeLocation is a location. + InputMediaTypeLocation InputMediaType = "location" + // InputMediaTypeVenue is a venue. + InputMediaTypeVenue InputMediaType = "venue" + // InputMediaTypeLivePhoto is a live photo. InputMediaTypeLivePhoto InputMediaType = "live_photo" // Since: Bot API 10.0 ) diff --git a/tgapi/bot_types.go b/tgapi/bot_types.go index c1f6e55..30059bf 100644 --- a/tgapi/bot_types.go +++ b/tgapi/bot_types.go @@ -6,6 +6,8 @@ package tgapi type BotCommand struct { Command string `json:"command"` Description string `json:"description"` + // IsEphemeral marks the command as visible only in ephemeral command contexts. + IsEphemeral bool `json:"is_ephemeral,omitempty"` // Since: Bot API 10.2 } // BotCommandScopeType indicates the type of a command scope. diff --git a/tgapi/chat_methods.go b/tgapi/chat_methods.go index 37d6349..00fd3ef 100644 --- a/tgapi/chat_methods.go +++ b/tgapi/chat_methods.go @@ -498,8 +498,10 @@ const ( // Since: Bot API 10.1 // See https://core.telegram.org/bots/api#answerchatjoinrequestquery type AnswerChatJoinRequestQuery struct { - ChatJoinRequestQueryID string `json:"chat_join_request_query_id"` - Result ChatJoinRequestQueryResult `json:"result"` + // ChatJoinRequestQueryID identifies the chat join request query. + ChatJoinRequestQueryID string `json:"chat_join_request_query_id"` + // Result contains the decision for the join request query. + Result ChatJoinRequestQueryResult `json:"result"` } // AnswerChatJoinRequestQuery processes a received chat join request query. @@ -524,8 +526,10 @@ func (api *API) AnswerChatJoinRequestQueryWithContext(ctx context.Context, param // Since: Bot API 10.1 // See https://core.telegram.org/bots/api#sendchatjoinrequestwebapp type SendChatJoinRequestWebApp struct { + // ChatJoinRequestQueryID identifies the chat join request query. ChatJoinRequestQueryID string `json:"chat_join_request_query_id"` - WebAppURL string `json:"web_app_url"` + // WebAppURL is the HTTPS URL of the Mini App to open. + WebAppURL string `json:"web_app_url"` } // SendChatJoinRequestWebApp shows a Mini App to the user before deciding a diff --git a/tgapi/chat_types.go b/tgapi/chat_types.go index 8ff5522..12616af 100644 --- a/tgapi/chat_types.go +++ b/tgapi/chat_types.go @@ -52,7 +52,6 @@ type ChatFullInfo struct { PersonalChat *Chat `json:"personal_chat,omitempty"` ParentChat *Chat `json:"parent_chat,omitempty"` // Since: Bot API 9.2 - GuardBot *User `json:"guard_bot,omitempty"` // Since: Bot API 10.1; visible to chat administrators only AvailableReaction []ReactionType `json:"available_reaction,omitempty"` @@ -92,6 +91,10 @@ type ChatFullInfo struct { FirstProfileAudio *Audio `json:"first_profile_audio,omitempty"` // Since: Bot API 9.4 UniqueGiftColors *UniqueGiftColors `json:"unique_gift_colors,omitempty"` // Since: Bot API 9.3 PaidMessageStarCount *int `json:"paid_message_star_count,omitempty"` // Since: Bot API 9.3 + // GuardBot contains the guard bot visible to chat administrators. + GuardBot *User `json:"guard_bot,omitempty"` // Since: Bot API 10.1; visible to chat administrators only + // Community contains information about the affected community. + Community *Community `json:"community,omitempty"` // Since: Bot API 10.2 } // ChatPhoto represents a chat photo. @@ -318,3 +321,26 @@ type ChatBoostRemoved struct { RemoveDate int `json:"remove_date"` Source ChatBoostSource `json:"source"` } + +// Community represents a group of chats. +// +// Since: Bot API 10.2 +type Community struct { + // ID uniquely identifies the value within its containing object. + ID int64 `json:"id"` + // Name is the user-facing or reference name of the value. + Name string `json:"name"` +} + +// CommunityChatAdded describes a service message about a chat joining a community. +// +// Since: Bot API 10.2 +type CommunityChatAdded struct { + // Community contains information about the affected community. + Community Community `json:"community"` +} + +// CommunityChatRemoved describes a service message about a chat leaving a community. +// +// Since: Bot API 10.2 +type CommunityChatRemoved struct{} diff --git a/tgapi/errors.go b/tgapi/errors.go index 8eb7cda..f7f599f 100644 --- a/tgapi/errors.go +++ b/tgapi/errors.go @@ -14,6 +14,11 @@ var ErrPoolQueueFull = errors.New("worker pool queue full") // ErrPoolStopped reports that a request was submitted after the worker pool stopped. var ErrPoolStopped = errors.New("worker pool stopped") +// ErrRichMessageDraftUploadUnsupported reports a direct file upload attempted for a rich draft. +// +// Since: Bot API 10.2 +var ErrRichMessageDraftUploadUnsupported = errors.New("sendRichMessageDraft does not support direct file uploads") + // ResponseError reports an unsuccessful Telegram API response. type ResponseError struct { Code int diff --git a/tgapi/inline_types.go b/tgapi/inline_types.go index 032d359..6b02051 100644 --- a/tgapi/inline_types.go +++ b/tgapi/inline_types.go @@ -20,6 +20,7 @@ type InlineQueryResultsButton struct { // Since: Bot API 10.1 // See https://core.telegram.org/bots/api#inputrichmessagecontent type InputRichMessageContent struct { + // RichMessage contains structured rich-message content. RichMessage InputRichMessage `json:"rich_message"` } diff --git a/tgapi/log_redaction.go b/tgapi/log_redaction.go new file mode 100644 index 0000000..a7f4a07 --- /dev/null +++ b/tgapi/log_redaction.go @@ -0,0 +1,57 @@ +package tgapi + +import ( + "encoding/json" + "fmt" + "strings" +) + +const redactedLogValue = "tag. +// +// Since: Bot API 10.2 +type InputRichBlockParagraph struct { + // Type is the Bot API type discriminator. + Type InputRichType `json:"type"` + // Text contains the formatted or plain text content. + Text RichText `json:"text"` +} + +func (InputRichBlockParagraph) isInputRichBlock() {} + +// InputRichBlockSectionHeading is a section heading corresponding to an HTML
and tags.
+//
+// Since: Bot API 10.2
+type InputRichBlockPreformatted struct {
+ // Type is the Bot API type discriminator.
+ Type InputRichType `json:"type"`
+ // Text contains the formatted or plain text content.
+ Text RichText `json:"text"`
+ // Language identifies the programming language used for syntax highlighting.
+ Language string `json:"language,omitempty"`
+}
+
+func (InputRichBlockPreformatted) isInputRichBlock() {}
+
+// InputRichBlockFooter is a footer corresponding to the HTML